**Die Wiedergabe sprang von selbst wieder an.** Auf dem Mac pausiert ein Klick ins Bild — und der Klick, mit dem man ein Fenster nach vorn holt, kommt ebenfalls dort an. Man aktiviert das Fenster und drückt dabei auf Play. Klicks innerhalb einer Viertelsekunde nach dem Aktivieren zählen deshalb nicht: länger braucht niemand bis zum ersten gemeinten Klick, kürzer erwischt den Fall nicht. **Schwarzbild mit Ton nach dem Vergrössern.** Kachel und Vollbild bestehen beim Wechsel kurz gleichzeitig, und beide holten sich dieselbe Bildfläche in ihre Hülle. Eine Ansicht hat aber nur einen Vorgesetzten — wer zuletzt aktualisiert, gewinnt, und das war nicht verlässlich die richtige. Blieb die Fläche bei der Kachel, die gleich darauf verschwand, lief im Vollbild der Ton weiter und das Bild blieb schwarz. Die Hülle allein genügte nicht: sie verhindert, dass das Abräumen der einen Ansicht der anderen etwas wegnimmt, entscheidet aber nicht, wer die Fläche bekommen **soll**. Das sagt jetzt `isActive` — abgeleitet aus `playback.presentation`, also aus derselben Quelle, die auch bestimmt, welche Ansicht überhaupt zu sehen ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
10 KiB
Swift
249 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,
|
|
isActive: playback.presentation == .mini
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|