Die Kachel ließ sich nicht mehr öffnen, und das war kein Zufall: das Antippen zeigte erst die Bedienelemente, und die zweite Berührung sollte vergrößern — traf dabei aber den Abspielknopf, der genau in der Mitte lag. Jede weitere Berührung landete wieder dort. Vergrößern war damit überhaupt nicht möglich. Jetzt gehört die Mitte dem Vergrößern, und zwar beim ersten Antippen. Die zwei Knöpfe — Pause und Schließen — sitzen dauerhaft sichtbar in den oberen Ecken, auf einer dunklen Scheibe, damit sie auf hellen Filmbildern nicht verschwinden. Unten bleibt der Fortschrittsbalken. Dass die Kachel „einfach weg" war, konnte ich nicht nachstellen; der eine Fall, den ich reproduziert habe, war mein eigener Attrappenserver mit einer Fortsetzungsstelle jenseits des Dateiendes. Zwei Dinge machen es trotzdem unwahrscheinlicher: die Knöpfe sind auch dann zu sehen, wenn das Bild schwarz bliebe — vorher war eine schwarze Kachel in einer fast schwarzen Oberfläche praktisch unsichtbar —, und die Bildfläche hängt jetzt in einer eigenen Hülle je Ansicht. Beim Wechsel zwischen Kachel und Vollbild gibt es beide kurz gleichzeitig, und eine `UIView` hat nur einen Vorgesetzten: wer sie zuletzt bekommt, gewinnt. Gab SwiftUI sie unmittelbar heraus, konnte das Abräumen der einen der anderen die Bildfläche wegnehmen. Dazu etwas, das mir beim Ausprobieren selbst passiert ist: der Aufmacher wanderte weiter, während ich nach „Abspielen" griff, und ich startete den falschen Film. Er hört jetzt bei der ersten Berührung auf zu wandern. Wer die Hand darauf hat, fährt selbst. Nachgesehen auf iPhone und iPad: verkleinern, antippen, wieder im Vollbild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
9.3 KiB
Swift
228 lines
9.3 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()
|
|
}
|
|
.padding(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)
|
|
}
|
|
}
|
|
}
|
|
}
|