Wer im Fernsehen etwas sieht und nebenher nachsehen will, was sonst läuft, musste bisher abbrechen. Plex' eigene Handy-Apps können das auch nicht — kein Grund, es hier genauso zu machen. Der eigentliche Umbau ist nicht die Kachel, sondern der Besitz. Die Wiedergabe hing an der Sichtbarkeit der Ansicht, und zwar dreifach: das Modell war `@State` im Player, `onDisappear` hat den Motor abgeräumt, und die Bildebene entstand in `makeUIView` neu. Zurückgehen hieß deshalb: Motor tot, Transcoder aus, beim Live-TV der Tuner freigegeben. Jetzt gehört das Modell der Wurzelansicht, die immer da ist. Verkleinern und Vergrößern wechseln nur noch die Darstellung; beendet wird die Wiedergabe an genau einer Stelle, und zwar weil jemand es will. Die Bildebene hält das Modell selbst — dasselbe Muster, das VLC seit jeher verwendet. Dieselbe `UIView` wandert zwischen Kachel und Vollbild, statt neu zu entstehen; sonst setzte das Bild bei jedem Wechsel neu an. Bild-in-Bild wandert mit und bekommt endlich einen Delegaten: bisher war es eine Einbahnstraße, aus der man nicht in die App zurückkam. Die Kachel schwebt und lässt sich in jede Ecke ziehen, statt als Leiste unten zu kleben — eine Leiste nähme jeder Liste Platz weg und müsste in jedem Bildschirm bedacht werden. Die Eckenberechnung steht in MiniPlayerPlacement und ist geprüft: in einer View wäre eine Kachel, die halb aus dem Bild ragt, erst auf dem Gerät aufgefallen. Der Pfeil oben verkleinert, ein neuer Knopf daneben beendet wirklich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.1 KiB
Swift
191 lines
7.1 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
|
|
|
|
/// Die Ecke bleibt über Starts hinweg erhalten. Wer die Kachel einmal
|
|
/// nach links unten gezogen hat, will sie beim nächsten Mal dort haben.
|
|
@AppStorage("miniPlayer.corner") private var storedCorner = MiniPlayerPlacement.Corner.bottomTrailing.rawValue
|
|
|
|
/// Verschiebung während des Ziehens, noch nicht eingerastet.
|
|
@State private var drag: CGSize = .zero
|
|
@State private var showsControls = false
|
|
@State private var hideTask: Task<Void, Never>?
|
|
|
|
private var corner: MiniPlayerPlacement.Corner {
|
|
MiniPlayerPlacement.Corner(rawValue: storedCorner) ?? .bottomTrailing
|
|
}
|
|
|
|
/// Auf dem iPad darf sie größer sein — dort ist genug Fläche, und bei
|
|
/// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts.
|
|
private var tileWidth: CGFloat { sizeClass == .regular ? 280 : 180 }
|
|
private var tileSize: CGSize {
|
|
CGSize(width: tileWidth, height: (tileWidth / FlixyAspectRatio.landscape).rounded())
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
|
|
let resting = placement.center(for: corner)
|
|
let position = placement.clamped(
|
|
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
|
|
)
|
|
|
|
tile
|
|
.frame(width: tileSize.width, height: tileSize.height)
|
|
.position(position)
|
|
.gesture(dragGesture(in: placement, from: resting))
|
|
// 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)
|
|
}
|
|
|
|
if showsControls {
|
|
controls.transition(.opacity)
|
|
}
|
|
|
|
// 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)
|
|
.onTapGesture {
|
|
// Antippen zeigt erst die Bedienelemente, dann öffnet es. Ein
|
|
// sofortiges Aufklappen bei jeder Berührung wäre lästig, wenn man
|
|
// die Kachel nur anfassen will.
|
|
if showsControls {
|
|
playback.expand()
|
|
} else {
|
|
reveal()
|
|
}
|
|
}
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(Text(model.title))
|
|
}
|
|
|
|
private var controls: some View {
|
|
ZStack {
|
|
Color.black.opacity(0.45)
|
|
|
|
Button {
|
|
model.togglePlayPause()
|
|
reveal()
|
|
} label: {
|
|
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.white)
|
|
.padding(FlixySpacing.snug)
|
|
.contentShape(.rect)
|
|
}
|
|
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
|
|
|
|
VStack {
|
|
HStack {
|
|
Spacer()
|
|
Button {
|
|
playback.close()
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.padding(FlixySpacing.snug)
|
|
.contentShape(.rect)
|
|
}
|
|
.accessibilityLabel(Text("miniPlayer.close"))
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
hideTask?.cancel()
|
|
drag = value.translation
|
|
}
|
|
.onEnded { value in
|
|
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
|
|
// dem Finger: sonst springt eine Kachel, die man an der Kante
|
|
// angefasst hat, in die falsche Hälfte.
|
|
let center = CGPoint(
|
|
x: resting.x + value.predictedEndTranslation.width,
|
|
y: resting.y + value.predictedEndTranslation.height
|
|
)
|
|
storedCorner = placement.nearestCorner(toCenter: center).rawValue
|
|
drag = .zero
|
|
}
|
|
}
|
|
|
|
/// Zeigt die Bedienelemente und blendet sie nach kurzer Zeit wieder aus.
|
|
private func reveal() {
|
|
withAnimation(.easeInOut(duration: 0.2)) { showsControls = true }
|
|
hideTask?.cancel()
|
|
hideTask = Task {
|
|
try? await Task.sleep(for: .seconds(3))
|
|
guard !Task.isCancelled else { return }
|
|
withAnimation(.easeInOut(duration: 0.2)) { showsControls = false }
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|