**Eine Taste zum Vergrößern** unten rechts auf der Kachel. Das Antippen der Fläche vergrößert weiterhin, aber auf derselben Fläche liegen schon das Ziehen und das Aufziehen — und was dabei danebengeht, sieht aus wie „reagiert nicht". Ein Knopf geht nicht daneben. **Ton und Untertitel getrennt.** Es sind zwei verschiedene Fragen: die Tonspur stellt man einmal am Anfang um, die Untertitel mitten im Film. In einem gemeinsamen Blatt scrollte man jedes Mal an der anderen Liste vorbei. Das Untertitel-Symbol ist gefüllt, solange welche laufen — so sieht man es, ohne das Blatt zu öffnen. Und eine Auswahl schließt das Blatt jetzt selbst. **AirPlay.** Nicht nachgebaut, sondern die Systemstelle: sie kennt Geräte, die eine App nie aufzählen könnte. Der Knopf erscheint nur beim Systemplayer — VLC zeichnet selbst und kann kein AirPlay, und ein Knopf, der dort ins Leere führte, wäre schlimmer als keiner. **Die Qualität steht als Text da**, nicht als drei Schieberegler: „1080p", „720p", „Auto". Bei „automatisch" steht dort, was gerade tatsächlich ankommt — das weiß nur der Player, und ihn zu fragen ist ehrlicher, als „Auto" hinzuschreiben und die Antwort schuldig zu bleiben. Dazu die Einstellungen mit farbigen Symbolen und einer Kopfkarte je Bereich. Das ist nicht nur Zierde: in einer Liste aus zehn gleich aussehenden Zeilen findet das Auge nichts wieder, und „das mit dem blauen Zahnrad" ist eine brauchbare Beschreibung. Die Farben sind fest und ausdrücklich **nicht** die Akzentfarbe — trüge alles dieselbe, unterschiede sie nichts mehr. Und der Aufmacher läuft jetzt unter der Seitenleiste weiter (`backgroundExtensionEffect`): die Ränder werden gespiegelt und weich fortgeschrieben, während Titel und Knöpfe stehen bleiben. Vorher endete das Bild an der Kante der Leiste, und beim Weiterwandern schob sich das nächste an einer harten Linie ins Bild. 263 Tests grün. Player-Knöpfe und Einstellungen angesehen; den Aufmacher hinter der Leiste konnte ich an den einfarbigen Flächen der Attrappe nicht sicher beurteilen — der Simulator ist dabei erneut abgestürzt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
10 KiB
Swift
245 lines
10 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
|
|
/// Die schwebende Videokachel.
|
|
///
|
|
/// Damit läuft weiter, was man gerade sieht, während man nach dem Nächsten
|
|
/// stöbert — der Grund, warum es sie gibt. Plex' eigene Handy-Apps reißen die
|
|
/// Wiedergabe beim Zurückgehen ab, und genau in dem Moment will man
|
|
/// weiterschauen und nebenher nachsehen, was sonst noch läuft.
|
|
///
|
|
/// Bewusst schwebend statt als Leiste am unteren Rand: eine Leiste nimmt jeder
|
|
/// Liste Platz weg und müsste in jedem Bildschirm berücksichtigt werden. Eine
|
|
/// Kachel schwebt darüber, und wem sie im Weg ist, der zieht sie weg.
|
|
struct MiniPlayer: View {
|
|
let model: PlayerModel
|
|
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
|
|
/// Seite und Höhe bleiben über Starts hinweg erhalten. Wer die Kachel
|
|
/// einmal nach links unten gezogen hat, will sie dort wiederfinden.
|
|
@AppStorage("miniPlayer.side") private var storedSide = MiniPlayerPlacement.Side.trailing.rawValue
|
|
/// Als Anteil, nicht in Punkten: beim Drehen bleibt sie so an derselben
|
|
/// Stelle, statt unten herauszufallen.
|
|
@AppStorage("miniPlayer.height") private var storedFraction = 0.85
|
|
|
|
/// Verschiebung während des Ziehens, noch nicht eingerastet.
|
|
@State private var drag: CGSize = .zero
|
|
|
|
private var side: MiniPlayerPlacement.Side {
|
|
MiniPlayerPlacement.Side(rawValue: storedSide) ?? .trailing
|
|
}
|
|
|
|
/// Die selbst eingestellte Breite. Null heißt „noch nie angefasst".
|
|
@AppStorage("miniPlayer.width") private var storedWidth: Double = 0
|
|
/// Vergrößerung während des Zusammen- oder Auseinanderziehens.
|
|
@State private var zoom: CGFloat = 1
|
|
/// Die zuletzt tatsächlich gezeigte Breite — schon begrenzt.
|
|
///
|
|
/// Ohne sie würde beim Loslassen der ungebremste Wert gespeichert, und
|
|
/// eine einmal zu weit gezogene Kachel bliebe für immer zu groß.
|
|
@State private var lastWidth: CGFloat = 0
|
|
|
|
/// Auf dem iPad größer voreingestellt — dort ist genug Fläche, und bei
|
|
/// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts.
|
|
private var defaultWidth: CGFloat { sizeClass == .regular ? 280 : 180 }
|
|
|
|
private func tileSize(in bounds: CGSize) -> CGSize {
|
|
let chosen = storedWidth > 0 ? CGFloat(storedWidth) : defaultWidth
|
|
// Nach oben durch den Bildschirm begrenzt: eine Kachel, die breiter
|
|
// ist als das Fenster, ließe sich nicht mehr wegziehen.
|
|
let upper = max(160, min(bounds.width * 0.75, 560))
|
|
let width = min(max(chosen * zoom, 140), upper)
|
|
return CGSize(width: width, height: (width / FlixyAspectRatio.landscape).rounded())
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let tileSize = tileSize(in: geometry.size)
|
|
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
|
|
let resting = placement.center(side: side, verticalFraction: storedFraction)
|
|
let position = placement.clamped(
|
|
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
|
|
)
|
|
|
|
tile
|
|
.frame(width: tileSize.width, height: tileSize.height)
|
|
.onChange(of: tileSize.width, initial: true) { _, width in
|
|
lastWidth = width
|
|
}
|
|
.position(position)
|
|
.gesture(dragGesture(in: placement, from: resting))
|
|
// Gleichzeitig, damit das Zusammenziehen nicht erst nach dem
|
|
// Loslassen greift — beides passiert mit denselben Fingern.
|
|
.simultaneousGesture(zoomGesture(in: geometry.size))
|
|
// Nur während des Ziehens ohne Animation, sonst würde die
|
|
// Kachel dem Finger hinterherfedern statt an ihm zu kleben.
|
|
.animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position)
|
|
}
|
|
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
|
}
|
|
|
|
private var tile: some View {
|
|
ZStack {
|
|
Color.black
|
|
|
|
VideoSurface(engine: model.engine, surface: model.surface)
|
|
|
|
if model.isBuffering {
|
|
ProgressView().tint(.white)
|
|
}
|
|
|
|
corners
|
|
|
|
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
|
|
// es gibt kein Ende, auf das er zulaufen könnte.
|
|
if !model.isLive, let fraction = progress {
|
|
VStack {
|
|
Spacer()
|
|
WatchProgressBar(fraction: fraction)
|
|
}
|
|
}
|
|
}
|
|
.clipShape(.rect(cornerRadius: FlixyRadius.card))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: FlixyRadius.card)
|
|
.strokeBorder(Color.flixySeparator, lineWidth: 0.5)
|
|
}
|
|
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
|
|
.contentShape(.rect)
|
|
// Antippen vergrößert, sofort. Vorher brauchte es zwei Berührungen:
|
|
// die erste holte die Bedienelemente hervor, die zweite sollte
|
|
// öffnen — traf dabei aber den Abspielknopf, der genau in der Mitte
|
|
// lag. Vergrößern war damit überhaupt nicht möglich.
|
|
.onTapGesture { playback.expand() }
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(Text(model.title))
|
|
}
|
|
|
|
/// Die beiden Knöpfe in den oberen Ecken.
|
|
///
|
|
/// Dauerhaft sichtbar statt auf Antippen hin. Die Fläche in der Mitte
|
|
/// gehört dem Vergrößern — sie ist das, was man an einer solchen Kachel
|
|
/// am häufigsten tut, und sie darf nicht mit einem Knopf belegt sein.
|
|
/// Unten bleibt frei für den Fortschrittsbalken.
|
|
private var corners: some View {
|
|
VStack {
|
|
HStack {
|
|
tileButton(
|
|
model.isPlaying ? "pause.fill" : "play.fill",
|
|
label: model.isPlaying ? "player.pause" : "player.play"
|
|
) {
|
|
model.togglePlayPause()
|
|
}
|
|
Spacer()
|
|
tileButton("xmark", label: "miniPlayer.close") {
|
|
playback.close()
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
HStack {
|
|
Spacer()
|
|
// Ein eigener Knopf, obwohl auch das Antippen der Fläche
|
|
// vergrößert. Beim Ziehen und Aufziehen liegen auf derselben
|
|
// Fläche schon zwei Gesten, und was daneben geht, sieht aus
|
|
// wie „reagiert nicht". Ein Knopf geht nicht daneben.
|
|
tileButton(
|
|
"arrow.up.left.and.arrow.down.right",
|
|
label: "miniPlayer.expand"
|
|
) {
|
|
playback.expand()
|
|
}
|
|
}
|
|
}
|
|
.padding(FlixySpacing.tight)
|
|
// Über dem Fortschrittsbalken, nicht darauf.
|
|
.padding(.bottom, FlixySpacing.tight)
|
|
}
|
|
|
|
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
|
|
///
|
|
/// Die dunkle Scheibe darunter ist nicht Zierde: ein weißes Symbol auf
|
|
/// einer hellen Szene verschwindet, und die Kachel zeigt Filmbilder, deren
|
|
/// Helligkeit sich jede Sekunde ändert.
|
|
private func tileButton(
|
|
_ symbol: String,
|
|
label: LocalizedStringKey,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: symbol)
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 26, height: 26)
|
|
.background(.black.opacity(0.55), in: .circle)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text(label))
|
|
}
|
|
|
|
/// Größer und kleiner durch Zusammen- und Auseinanderziehen.
|
|
///
|
|
/// Kein Griff mehr: der saß zwangsläufig irgendwo auf dem Bild, war klein
|
|
/// und stritt sich mit dem Verschieben um dieselbe Geste.
|
|
private func zoomGesture(in bounds: CGSize) -> some Gesture {
|
|
MagnifyGesture()
|
|
.onChanged { value in
|
|
zoom = value.magnification
|
|
}
|
|
.onEnded { _ in
|
|
// Die **begrenzte** Breite merken, nicht die gezogene: eine
|
|
// einmal zu weit aufgezogene Kachel bliebe sonst für immer zu
|
|
// groß, auch auf einem kleineren Bildschirm.
|
|
storedWidth = Double(lastWidth)
|
|
zoom = 1
|
|
}
|
|
}
|
|
|
|
private var progress: Double? {
|
|
guard let duration = model.duration, duration.seconds > 0 else { return nil }
|
|
return model.currentTime.seconds / duration.seconds
|
|
}
|
|
|
|
private func dragGesture(
|
|
in placement: MiniPlayerPlacement,
|
|
from resting: CGPoint
|
|
) -> some Gesture {
|
|
DragGesture()
|
|
.onChanged { value in
|
|
drag = value.translation
|
|
}
|
|
.onEnded { value in
|
|
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
|
|
// dem Finger: sonst springt eine Kachel, die man am Rand
|
|
// angefasst hat, auf die falsche Seite.
|
|
let center = placement.clamped(center: CGPoint(
|
|
x: resting.x + value.predictedEndTranslation.width,
|
|
y: resting.y + value.predictedEndTranslation.height
|
|
))
|
|
// Seitlich einrasten, in der Höhe **nicht** — dort bleibt sie,
|
|
// wohin man sie gezogen hat.
|
|
storedSide = placement.nearestSide(toCenter: center).rawValue
|
|
storedFraction = placement.verticalFraction(forCenter: center)
|
|
drag = .zero
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Legt die schwebende Kachel über diesen Bildschirm.
|
|
///
|
|
/// Bewusst hier und nicht ganz oben in der Wurzel: auf dem iPhone soll die
|
|
/// Seitenleiste die Kachel verdecken, wenn sie aufgeht, und nicht
|
|
/// umgekehrt. Deshalb liegt sie **innerhalb** des Inhalts.
|
|
func miniPlayer(_ model: PlayerModel?, isVisible: Bool) -> some View {
|
|
overlay {
|
|
if let model, isVisible {
|
|
MiniPlayer(model: model)
|
|
}
|
|
}
|
|
}
|
|
}
|