Mini-Player: die Wiedergabe überlebt das Zurückgehen

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>
This commit is contained in:
Scarriffle
2026-09-12 17:09:22 +02:00
co-authored by Claude Opus 5
parent d28df346f2
commit 26c909a2e2
11 changed files with 3269 additions and 2668 deletions
@@ -0,0 +1,84 @@
import Foundation
import CoreGraphics
/// Wo die schwebende Videokachel sitzt.
///
/// Eigener Typ statt Rechnungen in der Ansicht, aus demselben Grund wie bei
/// `GuideLayout`: hier geht lautlos schief, was man nicht sieht. Eine Kachel,
/// die halb aus dem Bild ragt, oder eine, die beim Loslassen in die falsche
/// Ecke springt, fällt erst auf dem Gerät auf — und dann als Gefühl, nicht
/// als Fehler.
public struct MiniPlayerPlacement: Sendable, Equatable {
public enum Corner: String, Sendable, CaseIterable, Codable {
case topLeading, topTrailing, bottomLeading, bottomTrailing
var isTop: Bool { self == .topLeading || self == .topTrailing }
var isLeading: Bool { self == .topLeading || self == .bottomLeading }
}
/// Der sichtbare Bereich, in dem die Kachel sich bewegen darf.
public let bounds: CGSize
public let tile: CGSize
/// Abstand zum Rand. Eine Kachel, die den Rand berührt, sieht aus wie ein
/// Fehler statt wie eine Entscheidung.
public let inset: CGFloat
public init(bounds: CGSize, tile: CGSize, inset: CGFloat = FlixySpacing.regular) {
self.bounds = bounds
self.tile = tile
self.inset = inset
}
/// Die Ecke, in die eine losgelassene Kachel gehört.
///
/// Entschieden wird nach dem **Mittelpunkt** der Kachel, nicht nach dem
/// Finger: sonst springt eine Kachel, die man an der oberen Kante
/// angefasst hat, in die falsche Hälfte.
public func nearestCorner(toCenter center: CGPoint) -> Corner {
let isLeading = center.x < bounds.width / 2
let isTop = center.y < bounds.height / 2
switch (isTop, isLeading) {
case (true, true): return .topLeading
case (true, false): return .topTrailing
case (false, true): return .bottomLeading
case (false, false): return .bottomTrailing
}
}
/// Die linke obere Ecke der Kachel für eine Ecke des Bildschirms.
public func origin(for corner: Corner) -> CGPoint {
CGPoint(
x: corner.isLeading ? inset : max(inset, bounds.width - tile.width - inset),
y: corner.isTop ? inset : max(inset, bounds.height - tile.height - inset)
)
}
/// Der Mittelpunkt der Kachel in einer Ecke — damit rechnet SwiftUI.
public func center(for corner: Corner) -> CGPoint {
let start = origin(for: corner)
return CGPoint(x: start.x + tile.width / 2, y: start.y + tile.height / 2)
}
/// Hält einen Mittelpunkt im sichtbaren Bereich.
///
/// Während des Ziehens, damit die Kachel nicht hinter den Rand rutscht und
/// dort unerreichbar wird — der Finger wäre dann schneller als das Ziel.
public func clamped(center: CGPoint) -> CGPoint {
let halfWidth = tile.width / 2
let halfHeight = tile.height / 2
let minX = inset + halfWidth
let maxX = bounds.width - inset - halfWidth
let minY = inset + halfHeight
let maxY = bounds.height - inset - halfHeight
return CGPoint(
// `max(min…)` in dieser Reihenfolge, weil bei einem Bereich, der
// schmaler ist als die Kachel, sonst der untere Anschlag verliert
// und die Kachel rechts hinausragt.
x: max(minX, min(maxX, center.x)),
y: max(minY, min(maxY, center.y))
)
}
}
@@ -0,0 +1,88 @@
import Foundation
import Testing
@testable import FlixyDesign
/// Wo die schwebende Videokachel landet.
@Suite("Kachel-Platzierung")
struct MiniPlayerPlacementTests {
/// Ein iPhone im Hochformat, Kachel 180 × 101.
private let placement = MiniPlayerPlacement(
bounds: CGSize(width: 390, height: 844),
tile: CGSize(width: 180, height: 101),
inset: 12
)
@Test("Jede Bildschirmhälfte führt in ihre Ecke")
func picksTheNearestCorner() {
#expect(placement.nearestCorner(toCenter: CGPoint(x: 50, y: 50)) == .topLeading)
#expect(placement.nearestCorner(toCenter: CGPoint(x: 340, y: 50)) == .topTrailing)
#expect(placement.nearestCorner(toCenter: CGPoint(x: 50, y: 800)) == .bottomLeading)
#expect(placement.nearestCorner(toCenter: CGPoint(x: 340, y: 800)) == .bottomTrailing)
}
@Test("Genau auf der Mitte gewinnt unten rechts")
func decidesTheTie() {
// Willkürlich, aber festgelegt: ohne eine Regel für den Gleichstand
// hinge das Ergebnis an Fließkomma-Zufall, und dieselbe Geste führte
// mal hierhin, mal dorthin.
let center = CGPoint(x: 195, y: 422)
#expect(placement.nearestCorner(toCenter: center) == .bottomTrailing)
}
@Test("In jeder Ecke bleibt der Sicherheitsabstand")
func keepsTheInset() {
for corner in MiniPlayerPlacement.Corner.allCases {
let start = placement.origin(for: corner)
#expect(start.x >= placement.inset)
#expect(start.y >= placement.inset)
#expect(start.x + placement.tile.width <= 390 - placement.inset)
#expect(start.y + placement.tile.height <= 844 - placement.inset)
}
}
@Test("Unten rechts sitzt die Kachel auch wirklich unten rechts")
func computesTheFarCorner() {
let start = placement.origin(for: .bottomTrailing)
#expect(start.x == 198.0)
#expect(start.y == 731.0)
}
@Test("Beim Ziehen rutscht nichts über den Rand")
func clampsWhileDragging() {
// Ohne diese Grenze zieht man die Kachel hinter den Bildschirmrand,
// und dort ist sie mit dem Finger nicht mehr einzuholen.
let farOut = placement.clamped(center: CGPoint(x: -500, y: -500))
#expect(farOut.x == 102.0)
#expect(farOut.y == 62.5)
let alsoFarOut = placement.clamped(center: CGPoint(x: 9_000, y: 9_000))
#expect(alsoFarOut.x == 288.0)
#expect(alsoFarOut.y == 781.5)
}
@Test("Ein Punkt in der Mitte bleibt, wo er ist")
func leavesValidPointsAlone() {
let inside = CGPoint(x: 195, y: 400)
#expect(placement.clamped(center: inside) == inside)
}
@Test("Ein zu enger Bereich schiebt die Kachel nicht hinaus")
func survivesTinyBounds() {
// Kommt beim Drehen und in der Übergangsphase vor: der Bereich ist
// kurzzeitig schmaler als die Kachel. Ein falsch herum geklammerter
// Wert schöbe sie dann dauerhaft aus dem Bild.
let narrow = MiniPlayerPlacement(
bounds: CGSize(width: 100, height: 80),
tile: CGSize(width: 180, height: 101),
inset: 12
)
#expect(narrow.origin(for: .bottomTrailing) == CGPoint(x: 12, y: 12))
#expect(narrow.clamped(center: CGPoint(x: 0, y: 0)) == CGPoint(x: 102, y: 62.5))
}
}