Der Fehler steckte in der Abfrage, und er ist grob:
beginsAt> = jetzt − 1 Std.
endsAt< = jetzt + 6 Std.
Beide Grenzen schließen genau das aus, was man sehen will. Ein
Zweistünder, der um halb vier begann, war um Viertel nach fünf
unsichtbar — und angezeigt wurde stattdessen die nächste Sendung, die
durch den Filter passte. Deshalb wich die Anzeige von Plex' eigener ab.
Jetzt wird nur der **Anfang** eingegrenzt, mit sechs Stunden Vorlauf für
lange Sendungen, und keine Grenze auf das Ende. Zurück kommt, was den
Zeitraum berührt, statt was vollständig hineinpasst.
Gefiltert wird danach nur das Programm, nicht die Senderliste: ein Sender
ohne frische Daten soll „kein Programm" sagen, statt zu verschwinden.
Dazu zwei Wünsche aus dem Feldtest:
Die Kachel lässt sich in der Größe ziehen. Der Griff sitzt in der Ecke,
die zur Bildschirmmitte zeigt — am äußeren Rand läge er halb außerhalb
des Daumens. Gespeichert wird die begrenzte Breite, nicht die gezogene,
sonst bliebe eine einmal zu weit gezogene Kachel für immer zu groß.
Und eine Senderauswahl. Über zwanzig Sender zu zeichnen und ihr Programm
zu holen kostet Zeit, und die meisten sieht man nie an. Eine leere
Auswahl heißt weiterhin „alle" — sonst stünde man beim ersten Öffnen vor
einer leeren Liste.
Die Zeiten im Raster-Abzug liegen jetzt in der Zukunft. Mit den echten
Zeitstempeln von damals wäre er ab dem Tag nach der Aufnahme leer
gewesen, sobald auf Frische gefiltert wird.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
9.9 KiB
Swift
252 lines
9.9 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
|
|
}
|
|
|
|
/// Die selbst eingestellte Breite. Null heißt „noch nie angefasst".
|
|
@AppStorage("miniPlayer.width") private var storedWidth: Double = 0
|
|
/// Änderung während des Ziehens am Griff, noch nicht übernommen.
|
|
@State private var widthChange: CGFloat = 0
|
|
/// 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 + widthChange, 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(for: corner)
|
|
let position = placement.clamped(
|
|
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
|
|
)
|
|
|
|
tile(corner: corner)
|
|
.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))
|
|
// 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 func tile(corner: MiniPlayerPlacement.Corner) -> some View {
|
|
ZStack {
|
|
Color.black
|
|
|
|
VideoSurface(engine: model.engine, surface: model.surface)
|
|
|
|
if model.isBuffering {
|
|
ProgressView().tint(.white)
|
|
}
|
|
|
|
if showsControls {
|
|
controls.transition(.opacity)
|
|
resizeHandle(corner: corner).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()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Der Griff zum Größerziehen.
|
|
///
|
|
/// Er sitzt in der Ecke, die zur Bildschirmmitte zeigt — dort ist er
|
|
/// erreichbar, egal in welcher Ecke die Kachel klebt. Am äußeren Rand
|
|
/// läge er halb außerhalb des Daumens.
|
|
private func resizeHandle(corner: MiniPlayerPlacement.Corner) -> some View {
|
|
VStack {
|
|
if corner.isTop { Spacer() }
|
|
HStack {
|
|
if corner.isLeading { Spacer() }
|
|
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.padding(FlixySpacing.snug)
|
|
.contentShape(.rect)
|
|
.gesture(resizeGesture(corner: corner))
|
|
.accessibilityLabel(Text("miniPlayer.resize"))
|
|
if !corner.isLeading { Spacer() }
|
|
}
|
|
if !corner.isTop { Spacer() }
|
|
}
|
|
// Gespiegelt, damit das Symbol in die Richtung zeigt, in die es
|
|
// tatsächlich größer wird.
|
|
.rotationEffect(.degrees(corner.isTop == corner.isLeading ? 0 : 90))
|
|
}
|
|
|
|
private func resizeGesture(corner: MiniPlayerPlacement.Corner) -> some Gesture {
|
|
DragGesture()
|
|
.onChanged { value in
|
|
hideTask?.cancel()
|
|
// Klebt die Kachel rechts, wächst sie beim Ziehen nach links.
|
|
widthChange = corner.isLeading ? value.translation.width : -value.translation.width
|
|
}
|
|
.onEnded { _ in
|
|
storedWidth = Double(lastWidth)
|
|
widthChange = 0
|
|
reveal()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|