Das Bild füllt den Schirm, und die Schrift bleibt darin
Vier Dinge am iPhone-Player, die zusammengehören, weil sie alle davon handeln, wo der Bildschirm aufhört. **Einpassen oder füllen.** `videoGravity` stand fest auf `.resizeAspect`, und zwar in einer Zeile, die nach jedem Neuaufbau erneut läuft — eine Wahl hätte dort also jeden Qualitätswechsel nicht überlebt. Jetzt steht sie im Modell, und die Zeile liest sie. Umgelegt wird mit zwei Fingern oder über einen Knopf; stufenlos wäre falsch, weil alles zwischen den zwei Zuständen ein Bild ist, das weder ganz zu sehen ist noch den Schirm füllt. Auf dem Mac erscheint der Knopf nicht: dort steht der Vollbildknopf darüber, und zwei Knöpfe, die beide „grösser" bedeuten, sind eine Zumutung. **Untertitel im Füllmodus.** Dort reicht das Bild über die untere Bildschirmkante hinaus, und genau dort zeichnet AVFoundation die Untertitel. Statt sie anzuschneiden, wird die Bildebene um den Sicherheitsrand eingerückt — rund zwanzig Punkt Bild gegen eine Zeile, die man lesen kann. Dafür bekommt die Bildfläche echtes Layout: eine `autoresizingMask` hält Ränder verhältnismässig, und ein fester Einzug von zwanzig Punkten wäre im Querformat plötzlich vierzig gewesen. **Die Bedienleiste kannte ihre Ränder nicht mehr.** Der Player bekommt `ignoresSafeArea()` von der Wurzel und sieht danach überall null — Schieberegler und Zeitangaben sassen im Home-Indikator, der Zurück-Pfeil hinter der Dynamic Island, wo ihn niemand drücken konnte. Gemessen wird jetzt eine Ebene weiter aussen, wo die Ränder noch gelten, und über die Umgebung nach innen gereicht. Dieselbe Bauart wie der Akzent. Die Zeile steht ganz am Ende der Kette: stünde sie vor dem Overlay, umschlösse das Overlay sie, und ausgerechnet der Player ginge leer aus. **Gedreht wird einmal, gesperrt wird nicht.** Die Maske blieb auf `.landscape` stehen — wer im Bett auf der Seite lag, konnte nicht mehr ins Hochformat drehen. Ein Film beginnt quer, weil er quer ist; danach gehört die Entscheidung dem Nutzer. Dazu ein Drehknopf für den Fall, dass die Ausrichtungssperre des Systems an ist, und ein Fehlerbehandler an `requestGeometryUpdate`: eine abgelehnte Drehung ist dann eine Einstellung und keine Panne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1ff65e823a
commit
4576eac67c
@@ -22,6 +22,18 @@ struct PlayerView: View {
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
/// Die Sicherheitsränder des Bildschirms.
|
||||
///
|
||||
/// **Nicht hier gemessen, sondern gereicht.** Diese Ansicht bekommt
|
||||
/// `ignoresSafeArea()` von der Wurzel und sieht danach überall null;
|
||||
/// gemessen wird eine Ebene weiter aussen. Siehe `flixyScreenInsets`.
|
||||
@Environment(\.flixyScreenInsets) private var screenInsets
|
||||
/// Woran sich Quer- und Hochformat unterscheiden lassen.
|
||||
///
|
||||
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
|
||||
/// Hochformat nicht. Billiger als die Szene zu befragen, und es
|
||||
/// aktualisiert sich von selbst.
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
/// Wie hoch die Fensterleiste auf dem Mac ist.
|
||||
///
|
||||
@@ -56,6 +68,15 @@ struct PlayerView: View {
|
||||
@State private var isFullScreen = false
|
||||
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
|
||||
private var isMac: Bool { FlixyPlatform.isMac }
|
||||
/// Ob gedreht und mit zwei Fingern gezoomt werden kann.
|
||||
///
|
||||
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
|
||||
/// Mac dreht sich gar nicht. Bleibt das Telefon.
|
||||
private var isPhone: Bool {
|
||||
!isMac && UIDevice.current.userInterfaceIdiom == .phone
|
||||
}
|
||||
/// Ob das Gerät gerade quer liegt.
|
||||
private var isLandscape: Bool { verticalSizeClass == .compact }
|
||||
|
||||
/// Wann der Zeiger zuletzt die Bedienelemente geholt hat.
|
||||
@State private var lastPointerReveal: Date = .distantPast
|
||||
@@ -86,6 +107,17 @@ struct PlayerView: View {
|
||||
surface(for: model)
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { primaryTap() }
|
||||
// **Auseinander füllt, zusammen passt ein.**
|
||||
//
|
||||
// Kein stufenloses Zoomen: es gibt genau zwei
|
||||
// sinnvolle Zustände, und alles dazwischen
|
||||
// wäre ein Bild, das weder ganz zu sehen ist
|
||||
// noch den Schirm füllt. Die Geste wählt also
|
||||
// aus zweien aus, statt einen Faktor zu
|
||||
// setzen — und der Schwellenwert liegt bewusst
|
||||
// weit vom Ruhepunkt weg, damit eine wackelige
|
||||
// Hand nichts umschaltet.
|
||||
.gesture(zoomGesture(for: model), including: isMac ? .none : .all)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +207,11 @@ struct PlayerView: View {
|
||||
.opacity(zeigt ? 1 : 0)
|
||||
.allowsHitTesting(zeigt)
|
||||
.padding(FlixySpacing.loose)
|
||||
// Siehe `controls(for:)`: die Kopfzeile liegt sonst hinter
|
||||
// der Dynamic Island, und der Zurück-Pfeil ist tot.
|
||||
.padding(.top, screenInsets.top)
|
||||
.padding(.leading, screenInsets.leading)
|
||||
.padding(.trailing, screenInsets.trailing)
|
||||
// **Unter der Fensterleiste beginnen.**
|
||||
//
|
||||
// Der Player deckt das ganze Fenster ab, und auf dem Mac
|
||||
@@ -306,6 +343,13 @@ struct PlayerView: View {
|
||||
scrubber(for: model)
|
||||
}
|
||||
.padding(FlixySpacing.loose)
|
||||
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
|
||||
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
|
||||
// soll. Ersetzte eines das andere, klebte der Schieberegler im
|
||||
// Querformat am Home-Indikator — genau dort stand er vorher.
|
||||
.padding(.leading, screenInsets.leading)
|
||||
.padding(.trailing, screenInsets.trailing)
|
||||
.padding(.bottom, screenInsets.bottom)
|
||||
.background {
|
||||
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
||||
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
|
||||
@@ -380,6 +424,42 @@ struct PlayerView: View {
|
||||
.keyboardShortcut("f", modifiers: [.command, .control])
|
||||
}
|
||||
|
||||
// **Einpassen oder füllen — überall ausser auf dem Mac.**
|
||||
//
|
||||
// Dort gibt es den Vollbildknopf darüber, und zwei Knöpfe, die
|
||||
// beide „grösser" bedeuten, nebeneinander sind eine Zumutung.
|
||||
if !isMac {
|
||||
Button {
|
||||
setFit(model.videoFit == .fill ? .fit : .fill, on: model)
|
||||
} label: {
|
||||
Image(systemName: model.videoFit == .fill
|
||||
? "arrow.down.right.and.arrow.up.left"
|
||||
: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.title3)
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo"))
|
||||
}
|
||||
|
||||
// **Drehen — nur am Telefon.**
|
||||
//
|
||||
// Ein iPad dreht sich von selbst, sobald man es dreht; ein Telefon
|
||||
// im Player auch, seit die Sperre weg ist. Der Knopf ist für den
|
||||
// Fall, dass die Ausrichtungssperre des Systems an ist — dann
|
||||
// dreht das Gerät nicht mehr von allein, und das ist meistens
|
||||
// genau dann, wenn man liegt.
|
||||
if isPhone {
|
||||
Button {
|
||||
OrientationLock.shared.rotate(to: isLandscape ? .portrait : .landscape)
|
||||
revealControls()
|
||||
} label: {
|
||||
Image(systemName: isLandscape ? "iphone" : "iphone.landscape")
|
||||
.font(.title3)
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.accessibilityLabel(Text("player.rotate"))
|
||||
}
|
||||
|
||||
// Getrennte Knöpfe, weil es zwei verschiedene Fragen sind: die
|
||||
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
|
||||
// Film. Ein gemeinsames Sheet bedeutete jedes Mal Scrollen an der
|
||||
@@ -589,9 +669,11 @@ struct PlayerView: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.section)
|
||||
.padding(.leading, screenInsets.leading)
|
||||
.padding(.trailing, screenInsets.trailing)
|
||||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||||
// die Bedienelemente eingeblendet sind.
|
||||
.padding(.bottom, showsControls ? 110 : FlixySpacing.section)
|
||||
.padding(.bottom, (showsControls ? 110 : FlixySpacing.section) + screenInsets.bottom)
|
||||
}
|
||||
.animation(.snappy, value: model.activeMarker?.id)
|
||||
.animation(.snappy, value: model.hasReachedEnd)
|
||||
@@ -739,18 +821,51 @@ struct PlayerView: View {
|
||||
let fläche = VideoSurface(
|
||||
engine: model.engine,
|
||||
surface: model.surface,
|
||||
isActive: playback.presentation == .full
|
||||
isActive: playback.presentation == .full,
|
||||
// **Nur im Füllmodus.** Eingepasst liegen unten ohnehin schwarze
|
||||
// Balken, in denen der Untertitel Platz hat; dort etwas
|
||||
// auszusparen nähme Bild, ohne etwas zu retten.
|
||||
bottomInset: model.videoFit == .fill ? screenInsets.bottom : 0
|
||||
)
|
||||
|
||||
if let breite = model.statistics?.width,
|
||||
let höhe = model.statistics?.height,
|
||||
breite > 0, höhe > 0 {
|
||||
// **Im Füllmodus kein Seitenverhältnis.** Die Fläche soll den Schirm
|
||||
// decken — das Einpassen ist genau das, was hier abgeschaltet wird.
|
||||
// Dass der schwarze Rand dann keine Klicks mehr abfängt, ist kein
|
||||
// Verlust: es gibt keinen.
|
||||
if model.videoFit == .fill {
|
||||
fläche
|
||||
} else if let breite = model.statistics?.width,
|
||||
let höhe = model.statistics?.height,
|
||||
breite > 0, höhe > 0 {
|
||||
fläche.aspectRatio(CGFloat(breite) / CGFloat(höhe), contentMode: .fit)
|
||||
} else {
|
||||
fläche
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Zweifingergeste, die zwischen einpassen und füllen umlegt.
|
||||
private func zoomGesture(for model: PlayerModel) -> some Gesture {
|
||||
MagnifyGesture(minimumScaleDelta: 0.1)
|
||||
.onEnded { wert in
|
||||
if wert.magnification > 1.1 {
|
||||
setFit(.fill, on: model)
|
||||
} else if wert.magnification < 0.9 {
|
||||
setFit(.fit, on: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legt die Skalierung um und zeigt dabei die Bedienelemente.
|
||||
///
|
||||
/// Das Einblenden gehört dazu: der Knopf wechselt sein Symbol, und wer
|
||||
/// nach der Geste nicht sieht, welcher Zustand nun gilt, probiert sie
|
||||
/// ein zweites Mal.
|
||||
private func setFit(_ neu: PlayerModel.VideoFit, on model: PlayerModel) {
|
||||
guard model.videoFit != neu else { return }
|
||||
model.videoFit = neu
|
||||
revealControls()
|
||||
}
|
||||
|
||||
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
|
||||
if case .playing = model.phase { return true }
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user