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:
co-authored by
Claude Opus 5
parent
d28df346f2
commit
26c909a2e2
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"%@ GB": {
|
||||
"comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"%@ → %@": {
|
||||
"comment": "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
|
||||
"isCommentAutoGenerated": true,
|
||||
@@ -13,8 +17,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ GB" : {
|
||||
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
||||
"1080p": {
|
||||
"comment": "A label for a 1080p video.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"4K": {
|
||||
@@ -25,8 +29,8 @@
|
||||
"comment": "A label for a 720p video.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"1080p" : {
|
||||
"comment" : "A label for a 1080p video.",
|
||||
"SD": {
|
||||
"comment": "A label for a 720p movie.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"action.apply": {
|
||||
@@ -2160,6 +2164,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.close": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wiedergabe beenden"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Stop playback"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.expand": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Vollbild"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Full screen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"placeholder.description": {
|
||||
"comment": "Platzhalter-Bildschirme",
|
||||
"localizations": {
|
||||
@@ -2259,6 +2295,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.minimize": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Verkleinern"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Minimize"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.nextEpisode": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2429,20 +2481,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitlesOff" : {
|
||||
"comment" : "Eintrag, der die Untertitel abschaltet",
|
||||
"extractionState" : "stale",
|
||||
"player.stop": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value" : "Aus"
|
||||
"value": "Beenden"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value" : "Off"
|
||||
"value": "Stop"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2464,6 +2514,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitlesOff": {
|
||||
"comment": "Eintrag, der die Untertitel abschaltet",
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Aus"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.tracks": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2686,10 +2754,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SD" : {
|
||||
"comment" : "A label for a 720p movie.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"search.hint": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
||||
@@ -36,19 +36,78 @@ struct PlaybackRequest: Identifiable, Hashable {
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PlaybackCoordinator {
|
||||
var request: PlaybackRequest?
|
||||
/// Wie der Player gerade zu sehen ist.
|
||||
///
|
||||
/// Der Unterschied zwischen „klein" und „gar nicht" ist der Kern der
|
||||
/// Sache: verkleinern darf die Wiedergabe **nicht** anhalten. Vorher gab
|
||||
/// es diese Unterscheidung nicht — der Player war da oder weg, und mit
|
||||
/// ihm der Motor, der Transcoder und beim Live-TV der belegte Tuner.
|
||||
enum Presentation: Equatable {
|
||||
case hidden
|
||||
case mini
|
||||
case full
|
||||
}
|
||||
|
||||
private(set) var request: PlaybackRequest?
|
||||
private(set) var presentation: Presentation = .hidden
|
||||
|
||||
func play(_ item: PlexItem, mediaIndex: Int = 0, resuming: Bool = true) {
|
||||
request = PlaybackRequest(
|
||||
play(PlaybackRequest(
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.playbackTitle,
|
||||
mediaIndex: mediaIndex,
|
||||
startTime: resuming ? item.resumeTime : .zero
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
/// Schaltet einen laufenden Sender ein.
|
||||
///
|
||||
/// Eigener Weg, weil ein Sender nicht in der Mediathek liegt: Adresse und
|
||||
/// Tuner-Belegung stehen schon fest und dürfen nicht neu ermittelt werden.
|
||||
func playLive(
|
||||
channelID: String,
|
||||
title: String,
|
||||
stream: URL,
|
||||
subscriptionKey: String?
|
||||
) {
|
||||
play(PlaybackRequest(
|
||||
ratingKey: channelID,
|
||||
title: title,
|
||||
mediaIndex: 0,
|
||||
startTime: .zero,
|
||||
streamURL: stream,
|
||||
liveSubscriptionKey: subscriptionKey
|
||||
))
|
||||
}
|
||||
|
||||
/// Übernimmt einen neuen Titel.
|
||||
///
|
||||
/// Läuft bereits die Kachel, bleibt es dabei: wer nebenher stöbert, will
|
||||
/// beim Antippen nicht ins Vollbild gerissen werden. Sonst Vollbild, wie
|
||||
/// man es von einem Antippen erwartet.
|
||||
func play(_ next: PlaybackRequest) {
|
||||
presentation = presentation == .mini ? .mini : .full
|
||||
request = next
|
||||
}
|
||||
|
||||
/// Verkleinert den Player zur schwebenden Kachel. Die Wiedergabe läuft.
|
||||
func minimize() {
|
||||
guard request != nil else { return }
|
||||
presentation = .mini
|
||||
}
|
||||
|
||||
func expand() {
|
||||
guard request != nil else { return }
|
||||
presentation = .full
|
||||
}
|
||||
|
||||
/// Beendet die Wiedergabe wirklich.
|
||||
///
|
||||
/// Die einzige Stelle, an der das passiert. Alles andere — verkleinern,
|
||||
/// vergrößern, Bild-in-Bild — lässt den Motor am Leben.
|
||||
func close() {
|
||||
request = nil
|
||||
presentation = .hidden
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,12 +114,12 @@ struct DownloadsView: View {
|
||||
|
||||
private func play(_ item: DownloadItem) {
|
||||
guard item.state == .completed else { return }
|
||||
playback.request = PlaybackRequest(
|
||||
playback.play(PlaybackRequest(
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
mediaIndex: 0,
|
||||
startTime: .zero
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -226,13 +226,11 @@ struct LiveTVView: View {
|
||||
|
||||
// Live-TV läuft über denselben Player wie alles andere, bekommt aber
|
||||
// die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht.
|
||||
playback.request = PlaybackRequest(
|
||||
ratingKey: channel.id,
|
||||
playback.playLive(
|
||||
channelID: channel.id,
|
||||
title: channel.title,
|
||||
mediaIndex: 0,
|
||||
startTime: .zero,
|
||||
streamURL: stream,
|
||||
liveSubscriptionKey: result.subscriptionKey
|
||||
stream: stream,
|
||||
subscriptionKey: result.subscriptionKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyDownloads
|
||||
import FlixyPlayback
|
||||
import PlexKit
|
||||
|
||||
/// Die Wurzelansicht.
|
||||
@@ -12,6 +14,17 @@ struct RootView: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(FlixyTheme.self) private var theme
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
|
||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||
///
|
||||
/// Damit überlebt die Wiedergabe den Wechsel zwischen Kachel und
|
||||
/// Vollbild. Läge das Modell im Player selbst, stürbe es mit ihm, und
|
||||
/// genau das ist vorher passiert.
|
||||
@State private var player: PlayerModel?
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
@State private var selection: SidebarDestination = .home
|
||||
@@ -61,8 +74,20 @@ struct RootView: View {
|
||||
.tint(theme.accentColor)
|
||||
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
|
||||
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
|
||||
.fullScreenCover(item: Bindable(playback).request) { request in
|
||||
PlayerView(request: request)
|
||||
// Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und
|
||||
// die Bildfläche müsste bei jedem Wechsel zwischen Kachel und
|
||||
// Vollbild umziehen. Als Schicht im selben Baum bleibt sie, wo sie
|
||||
// ist, und der Übergang ist ruhig.
|
||||
.overlay {
|
||||
if playback.presentation == .full, let player {
|
||||
PlayerView(model: player)
|
||||
.ignoresSafeArea()
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
.animation(.snappy(duration: 0.3), value: playback.presentation)
|
||||
.onChange(of: playback.request) { _, request in
|
||||
Task { await handOver(to: request) }
|
||||
}
|
||||
.task { await session.restore() }
|
||||
.onChange(of: session.phase) { _, newPhase in
|
||||
@@ -72,6 +97,42 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wechselt den laufenden Titel.
|
||||
///
|
||||
/// Das Neue wird zuerst aufgebaut, das Alte **nebenher** beendet. Anders
|
||||
/// herum säße man nach dem Antippen sekundenlang vor einem leeren Bild:
|
||||
/// das Abräumen spricht mit dem Server — Transcoder anhalten, beim
|
||||
/// Live-TV den Tuner freigeben —, und das darf den neuen Titel nicht
|
||||
/// aufhalten. Ein kurzzeitig doppelt belegter Tuner ist der kleinere
|
||||
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
|
||||
/// abgestimmt, bevor es hier ankommt.
|
||||
private func handOver(to request: PlaybackRequest?) async {
|
||||
let previous = player
|
||||
|
||||
guard let request else {
|
||||
player = nil
|
||||
if let previous { await previous.stop() }
|
||||
return
|
||||
}
|
||||
|
||||
let fresh = PlayerModel(
|
||||
request: request,
|
||||
server: session.server,
|
||||
downloads: downloads,
|
||||
diagnostics: diagnostics,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
isLocalConnection: session.server?.connection.local ?? false
|
||||
)
|
||||
// Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in
|
||||
// der Kachel — dort kam man vorher gar nicht mehr heraus.
|
||||
fresh.onPictureInPictureRestore { playback.expand() }
|
||||
|
||||
player = fresh
|
||||
if let previous { Task { await previous.stop() } }
|
||||
await fresh.start()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var library: some View {
|
||||
if sizeClass == .regular {
|
||||
@@ -96,6 +157,7 @@ struct RootView: View {
|
||||
.navigationTitle(Branding.appName)
|
||||
} detail: {
|
||||
NavigationStack { destination }
|
||||
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +181,9 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Innerhalb des Inhalts, nicht darüber: fährt die Seitenleiste
|
||||
// aus, soll sie die Kachel verdecken und nicht umgekehrt.
|
||||
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import AVKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import FlixyDownloads
|
||||
@@ -57,6 +58,21 @@ final class PlayerModel {
|
||||
/// Ob gerade auf eine andere Stufe umgestellt wird.
|
||||
private(set) var isChangingQuality = false
|
||||
|
||||
/// Die Bildebene des Systemplayers.
|
||||
///
|
||||
/// Gehört dem Modell, nicht der Ansicht: das Modell überlebt den Wechsel
|
||||
/// zwischen Kachel und Vollbild, die Ansicht nicht. Läge sie dort, entstünde
|
||||
/// bei jedem Wechsel eine neue `AVPlayerLayer`, und das Bild setzte neu an.
|
||||
let surface = PlayerLayerView()
|
||||
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
|
||||
///
|
||||
/// `@ObservationIgnored`, weil keine Ansicht darauf zeichnet — und weil
|
||||
/// `@Observable` einen `lazy`-Delegaten nicht überlebt.
|
||||
@ObservationIgnored private var pictureInPicture: AVPictureInPictureController?
|
||||
@ObservationIgnored private var pictureInPictureDelegate: PictureInPictureBridge?
|
||||
/// Was beim Beenden von Bild-in-Bild wieder ins Vollbild führen soll.
|
||||
@ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)?
|
||||
|
||||
private let request: PlaybackRequest
|
||||
private let server: PlexServer?
|
||||
private let downloads: DownloadCoordinator
|
||||
@@ -280,6 +296,7 @@ final class PlayerModel {
|
||||
|
||||
let engine = makeEngine(for: decision)
|
||||
self.engine = engine
|
||||
attachSurface(to: engine)
|
||||
observe(engine)
|
||||
|
||||
do {
|
||||
@@ -395,6 +412,9 @@ final class PlayerModel {
|
||||
|
||||
engine?.teardown()
|
||||
engine = nil
|
||||
pictureInPicture = nil
|
||||
pictureInPictureDelegate = nil
|
||||
surface.playerLayer.player = nil
|
||||
}
|
||||
|
||||
// MARK: - Fortschritt melden
|
||||
@@ -473,6 +493,40 @@ final class PlayerModel {
|
||||
return item.media[request.mediaIndex]
|
||||
}
|
||||
|
||||
/// Hängt die gehaltene Bildebene an den aktuellen Motor.
|
||||
///
|
||||
/// Nach **jedem** Motorwechsel nötig — also auch nach einem
|
||||
/// Qualitätswechsel, der den Motor neu baut. Ohne das zeigt die Ebene
|
||||
/// weiter auf einen abgeräumten Player und bleibt schwarz.
|
||||
private func attachSurface(to engine: any VideoPlayerEngine) {
|
||||
guard let avEngine = engine as? AVPlayerEngine else { return }
|
||||
|
||||
surface.playerLayer.player = avEngine.player
|
||||
surface.playerLayer.videoGravity = .resizeAspect
|
||||
|
||||
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
|
||||
if pictureInPicture == nil {
|
||||
let bridge = PictureInPictureBridge { [weak self] in
|
||||
self?.restoreFromPictureInPicture?()
|
||||
}
|
||||
let controller = AVPictureInPictureController(playerLayer: surface.playerLayer)
|
||||
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
|
||||
// Wiedergabe anzuhalten.
|
||||
controller?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
controller?.delegate = bridge
|
||||
pictureInPictureDelegate = bridge
|
||||
pictureInPicture = controller
|
||||
}
|
||||
}
|
||||
|
||||
/// Was passieren soll, wenn der Nutzer aus Bild-in-Bild zurückkehrt.
|
||||
///
|
||||
/// Bisher gab es das nicht: Bild-in-Bild war eine Einbahnstraße, weil
|
||||
/// niemand den Delegaten gesetzt hat.
|
||||
func onPictureInPictureRestore(_ action: @escaping () -> Void) {
|
||||
restoreFromPictureInPicture = action
|
||||
}
|
||||
|
||||
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
|
||||
switch decision.engine {
|
||||
case .vlc: VLCPlayerEngine()
|
||||
@@ -565,3 +619,26 @@ extension MediaTrack {
|
||||
return normalized == code.lowercased().prefix(2)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Verbindet Bild-in-Bild mit der App.
|
||||
///
|
||||
/// Ein eigener Typ, weil `AVPictureInPictureControllerDelegate` von `NSObject`
|
||||
/// erben muss und `PlayerModel` ein `@Observable` ist. Ohne einen Delegaten
|
||||
/// war Bild-in-Bild eine Einbahnstraße: man kam hinein, aber nicht zurück in
|
||||
/// den Player.
|
||||
private final class PictureInPictureBridge: NSObject, AVPictureInPictureControllerDelegate {
|
||||
private let restore: () -> Void
|
||||
|
||||
init(restore: @escaping () -> Void) {
|
||||
self.restore = restore
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ controller: AVPictureInPictureController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completion: @escaping (Bool) -> Void
|
||||
) {
|
||||
restore()
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import PlexKit
|
||||
|
||||
/// Der Vollbildplayer.
|
||||
struct PlayerView: View {
|
||||
let request: PlaybackRequest
|
||||
/// Das Modell gehört der Wurzel, nicht dieser Ansicht.
|
||||
///
|
||||
/// Vorher wurde es hier gebaut und beim Verschwinden abgeräumt — und
|
||||
/// genau daran starb die Wiedergabe, sobald man zurückging. Jetzt
|
||||
/// überdauert es die Ansicht, und das Beenden ist eine Entscheidung des
|
||||
/// Nutzers statt eine Nebenwirkung der Navigation.
|
||||
let model: PlayerModel
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@@ -16,7 +22,6 @@ struct PlayerView: View {
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var model: PlayerModel?
|
||||
@State private var showsControls = true
|
||||
@State private var showsTrackPicker = false
|
||||
@State private var showsQualityChoice = false
|
||||
@@ -27,8 +32,8 @@ struct PlayerView: View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
if let model {
|
||||
VideoSurface(engine: model.engine)
|
||||
Group {
|
||||
VideoSurface(engine: model.engine, surface: model.surface)
|
||||
.ignoresSafeArea()
|
||||
|
||||
switch model.phase {
|
||||
@@ -73,27 +78,25 @@ struct PlayerView: View {
|
||||
// Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind.
|
||||
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
|
||||
// und im Querformat erst recht nicht.
|
||||
.animation(.easeInOut(duration: 0.2), value: model?.isBuffering)
|
||||
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
|
||||
.statusBarHidden(true)
|
||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||
.landscapeWhileVisible()
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { toggleControls() }
|
||||
.sheet(isPresented: $showsTrackPicker) {
|
||||
if let model { TrackPickerSheet(model: model) }
|
||||
TrackPickerSheet(model: model)
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("player.quality"),
|
||||
isPresented: $showsQualityChoice,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let model {
|
||||
ForEach(PlayerQuality.allCases) { option in
|
||||
Button(qualityChoiceLabel(option, current: model.quality)) {
|
||||
Task { await model.select(quality: option) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
}
|
||||
.onChange(of: showsQualityChoice) { _, isShown in
|
||||
@@ -104,29 +107,8 @@ struct PlayerView: View {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { showsControls = true }
|
||||
scheduleControlsHide()
|
||||
}
|
||||
.task {
|
||||
let model = PlayerModel(
|
||||
request: request,
|
||||
server: session.server,
|
||||
downloads: downloads,
|
||||
diagnostics: diagnostics,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
isLocalConnection: session.server?.connection.local ?? false
|
||||
)
|
||||
self.model = model
|
||||
await model.start()
|
||||
scheduleControlsHide()
|
||||
}
|
||||
.onDisappear {
|
||||
hideTask?.cancel()
|
||||
// Losgelöst von der Ansicht: das Anhalten des Transcoders und die
|
||||
// Schlussmeldung sollen auch dann durchlaufen, wenn die Ansicht
|
||||
// schon verschwunden ist.
|
||||
if let model {
|
||||
Task { await model.stop() }
|
||||
}
|
||||
}
|
||||
.task { scheduleControlsHide() }
|
||||
.onDisappear { hideTask?.cancel() }
|
||||
}
|
||||
|
||||
// MARK: - Bedienelemente
|
||||
@@ -151,14 +133,27 @@ struct PlayerView: View {
|
||||
|
||||
private func topBar(for model: PlayerModel) -> some View {
|
||||
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
||||
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
|
||||
// Zweck der Übung: weiterschauen, während man stöbert.
|
||||
Button {
|
||||
playback.dismiss()
|
||||
playback.minimize()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.title3)
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.accessibilityLabel(Text("player.close"))
|
||||
.accessibilityLabel(Text("player.minimize"))
|
||||
|
||||
// Und daneben der Weg, wirklich Schluss zu machen — sonst gäbe es
|
||||
// keinen mehr, seit der Pfeil nur noch verkleinert.
|
||||
Button {
|
||||
playback.close()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.callout.weight(.semibold))
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.accessibilityLabel(Text("player.stop"))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.title)
|
||||
@@ -294,7 +289,7 @@ struct PlayerView: View {
|
||||
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
|
||||
Button {
|
||||
if let next = model.nextEpisodeRequest() {
|
||||
playback.request = next
|
||||
playback.play(next)
|
||||
}
|
||||
} label: {
|
||||
Label("player.nextEpisode", systemImage: "forward.end.fill")
|
||||
@@ -362,7 +357,7 @@ struct PlayerView: View {
|
||||
.font(.flixyBody)
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("action.close") { playback.dismiss() }
|
||||
Button("action.close") { playback.close() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
}
|
||||
@@ -397,7 +392,7 @@ struct PlayerView: View {
|
||||
hideTask?.cancel()
|
||||
hideTask = Task {
|
||||
try? await Task.sleep(for: .seconds(3.5))
|
||||
guard !Task.isCancelled, model?.isPlaying == true else { return }
|
||||
guard !Task.isCancelled, model.isPlaying else { return }
|
||||
withAnimation(.easeInOut(duration: 0.3)) { showsControls = false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,20 @@ import FlixyVLC
|
||||
/// `UIView`.
|
||||
struct VideoSurface: View {
|
||||
let engine: (any VideoPlayerEngine)?
|
||||
/// Die Bildebene des Systemplayers, gehalten vom Modell.
|
||||
///
|
||||
/// **Nicht** hier gebaut. Eine `AVPlayerLayer`, die in `makeUIView`
|
||||
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
|
||||
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
|
||||
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
|
||||
/// VLC macht es seit jeher richtig und reicht seine `videoView` durch;
|
||||
/// hier ist es jetzt derselbe Weg.
|
||||
let surface: PlayerLayerView
|
||||
|
||||
var body: some View {
|
||||
switch engine {
|
||||
case let avEngine as AVPlayerEngine:
|
||||
AVPlayerSurface(player: avEngine.player)
|
||||
case is AVPlayerEngine:
|
||||
UIViewContainer(view: surface)
|
||||
case let vlcEngine as VLCPlayerEngine:
|
||||
UIViewContainer(view: vlcEngine.videoView)
|
||||
default:
|
||||
@@ -24,37 +33,6 @@ struct VideoSurface: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Bildebene des Systemplayers, samt Bild-in-Bild.
|
||||
private struct AVPlayerSurface: UIViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeUIView(context: Context) -> PlayerLayerView {
|
||||
let view = PlayerLayerView()
|
||||
view.playerLayer.player = player
|
||||
view.playerLayer.videoGravity = .resizeAspect
|
||||
context.coordinator.setUp(with: view.playerLayer)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PlayerLayerView, context: Context) {
|
||||
uiView.playerLayer.player = player
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
final class Coordinator {
|
||||
private var pictureInPicture: AVPictureInPictureController?
|
||||
|
||||
func setUp(with layer: AVPlayerLayer) {
|
||||
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
|
||||
pictureInPicture = AVPictureInPictureController(playerLayer: layer)
|
||||
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
|
||||
// Wiedergabe anzuhalten.
|
||||
pictureInPicture?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
|
||||
///
|
||||
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet,
|
||||
@@ -66,6 +44,9 @@ final class PlayerLayerView: UIView {
|
||||
}
|
||||
|
||||
/// Hängt eine bestehende `UIView` in den SwiftUI-Baum.
|
||||
///
|
||||
/// Umhängen statt neu bauen: dieselbe `UIView` wandert zwischen Kachel und
|
||||
/// Vollbild hin und her, und die Wiedergabe merkt davon nichts.
|
||||
private struct UIViewContainer: UIViewRepresentable {
|
||||
let view: UIView
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user