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>
89 lines
3.3 KiB
Swift
89 lines
3.3 KiB
Swift
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))
|
||
}
|
||
}
|