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
+77 -13
View File
@@ -1,6 +1,10 @@
{ {
"sourceLanguage": "en", "sourceLanguage": "en",
"strings": { "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.", "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, "isCommentAutoGenerated": true,
@@ -13,8 +17,8 @@
} }
} }
}, },
"%@ GB" : { "1080p": {
"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.", "comment": "A label for a 1080p video.",
"isCommentAutoGenerated": true "isCommentAutoGenerated": true
}, },
"4K": { "4K": {
@@ -25,8 +29,8 @@
"comment": "A label for a 720p video.", "comment": "A label for a 720p video.",
"isCommentAutoGenerated": true "isCommentAutoGenerated": true
}, },
"1080p" : { "SD": {
"comment" : "A label for a 1080p video.", "comment": "A label for a 720p movie.",
"isCommentAutoGenerated": true "isCommentAutoGenerated": true
}, },
"action.apply": { "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": { "placeholder.description": {
"comment": "Platzhalter-Bildschirme", "comment": "Platzhalter-Bildschirme",
"localizations": { "localizations": {
@@ -2259,6 +2295,22 @@
} }
} }
}, },
"player.minimize": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Verkleinern"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Minimize"
}
}
}
},
"player.nextEpisode": { "player.nextEpisode": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -2429,20 +2481,18 @@
} }
} }
}, },
"player.subtitlesOff" : { "player.stop": {
"comment" : "Eintrag, der die Untertitel abschaltet",
"extractionState" : "stale",
"localizations": { "localizations": {
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value" : "Aus" "value": "Beenden"
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "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": { "player.tracks": {
"localizations": { "localizations": {
"de": { "de": {
@@ -2686,10 +2754,6 @@
} }
} }
}, },
"SD" : {
"comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true
},
"search.hint": { "search.hint": {
"localizations": { "localizations": {
"de": { "de": {
+63 -4
View File
@@ -36,19 +36,78 @@ struct PlaybackRequest: Identifiable, Hashable {
@MainActor @MainActor
@Observable @Observable
final class PlaybackCoordinator { 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) { func play(_ item: PlexItem, mediaIndex: Int = 0, resuming: Bool = true) {
request = PlaybackRequest( play(PlaybackRequest(
ratingKey: item.ratingKey, ratingKey: item.ratingKey,
title: item.playbackTitle, title: item.playbackTitle,
mediaIndex: mediaIndex, mediaIndex: mediaIndex,
startTime: resuming ? item.resumeTime : .zero 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 request = nil
presentation = .hidden
} }
} }
+2 -2
View File
@@ -114,12 +114,12 @@ struct DownloadsView: View {
private func play(_ item: DownloadItem) { private func play(_ item: DownloadItem) {
guard item.state == .completed else { return } guard item.state == .completed else { return }
playback.request = PlaybackRequest( playback.play(PlaybackRequest(
ratingKey: item.ratingKey, ratingKey: item.ratingKey,
title: item.title, title: item.title,
mediaIndex: 0, mediaIndex: 0,
startTime: .zero startTime: .zero
) ))
} }
} }
+4 -6
View File
@@ -226,13 +226,11 @@ struct LiveTVView: View {
// Live-TV läuft über denselben Player wie alles andere, bekommt aber // Live-TV läuft über denselben Player wie alles andere, bekommt aber
// die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht. // die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht.
playback.request = PlaybackRequest( playback.playLive(
ratingKey: channel.id, channelID: channel.id,
title: channel.title, title: channel.title,
mediaIndex: 0, stream: stream,
startTime: .zero, subscriptionKey: result.subscriptionKey
streamURL: stream,
liveSubscriptionKey: result.subscriptionKey
) )
} }
} }
+67 -2
View File
@@ -1,5 +1,7 @@
import SwiftUI import SwiftUI
import FlixyDesign import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import PlexKit import PlexKit
/// Die Wurzelansicht. /// Die Wurzelansicht.
@@ -12,6 +14,17 @@ struct RootView: View {
@Environment(FlixySession.self) private var session @Environment(FlixySession.self) private var session
@Environment(FlixyTheme.self) private var theme @Environment(FlixyTheme.self) private var theme
@Environment(PlaybackCoordinator.self) private var playback @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 @Environment(\.horizontalSizeClass) private var sizeClass
@State private var selection: SidebarDestination = .home @State private var selection: SidebarDestination = .home
@@ -61,8 +74,20 @@ struct RootView: View {
.tint(theme.accentColor) .tint(theme.accentColor)
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich // Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs. // etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
.fullScreenCover(item: Bindable(playback).request) { request in // Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und
PlayerView(request: request) // 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() } .task { await session.restore() }
.onChange(of: session.phase) { _, newPhase in .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 @ViewBuilder
private var library: some View { private var library: some View {
if sizeClass == .regular { if sizeClass == .regular {
@@ -96,6 +157,7 @@ struct RootView: View {
.navigationTitle(Branding.appName) .navigationTitle(Branding.appName)
} detail: { } detail: {
NavigationStack { destination } 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)
} }
} }
+190
View File
@@ -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)
}
}
}
}
+77
View File
@@ -1,3 +1,4 @@
import AVKit
import Foundation import Foundation
import Observation import Observation
import FlixyDownloads import FlixyDownloads
@@ -57,6 +58,21 @@ final class PlayerModel {
/// Ob gerade auf eine andere Stufe umgestellt wird. /// Ob gerade auf eine andere Stufe umgestellt wird.
private(set) var isChangingQuality = false 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 request: PlaybackRequest
private let server: PlexServer? private let server: PlexServer?
private let downloads: DownloadCoordinator private let downloads: DownloadCoordinator
@@ -280,6 +296,7 @@ final class PlayerModel {
let engine = makeEngine(for: decision) let engine = makeEngine(for: decision)
self.engine = engine self.engine = engine
attachSurface(to: engine)
observe(engine) observe(engine)
do { do {
@@ -395,6 +412,9 @@ final class PlayerModel {
engine?.teardown() engine?.teardown()
engine = nil engine = nil
pictureInPicture = nil
pictureInPictureDelegate = nil
surface.playerLayer.player = nil
} }
// MARK: - Fortschritt melden // MARK: - Fortschritt melden
@@ -473,6 +493,40 @@ final class PlayerModel {
return item.media[request.mediaIndex] 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 { private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
switch decision.engine { switch decision.engine {
case .vlc: VLCPlayerEngine() case .vlc: VLCPlayerEngine()
@@ -565,3 +619,26 @@ extension MediaTrack {
return normalized == code.lowercased().prefix(2) 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)
}
}
+31 -36
View File
@@ -6,7 +6,13 @@ import PlexKit
/// Der Vollbildplayer. /// Der Vollbildplayer.
struct PlayerView: View { 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(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackCoordinator.self) private var playback
@@ -16,7 +22,6 @@ struct PlayerView: View {
@Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(PlaybackDiagnostics.self) private var diagnostics
@Environment(\.flixyAccent) private var accent @Environment(\.flixyAccent) private var accent
@State private var model: PlayerModel?
@State private var showsControls = true @State private var showsControls = true
@State private var showsTrackPicker = false @State private var showsTrackPicker = false
@State private var showsQualityChoice = false @State private var showsQualityChoice = false
@@ -27,8 +32,8 @@ struct PlayerView: View {
ZStack { ZStack {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
if let model { Group {
VideoSurface(engine: model.engine) VideoSurface(engine: model.engine, surface: model.surface)
.ignoresSafeArea() .ignoresSafeArea()
switch model.phase { switch model.phase {
@@ -73,27 +78,25 @@ struct PlayerView: View {
// Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind. // Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind.
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen — // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
// und im Querformat erst recht nicht. // und im Querformat erst recht nicht.
.animation(.easeInOut(duration: 0.2), value: model?.isBuffering) .animation(.easeInOut(duration: 0.2), value: model.isBuffering)
.statusBarHidden(true) .statusBarHidden(true)
.persistentSystemOverlays(showsControls ? .automatic : .hidden) .persistentSystemOverlays(showsControls ? .automatic : .hidden)
.landscapeWhileVisible() .landscapeWhileVisible()
.contentShape(.rect) .contentShape(.rect)
.onTapGesture { toggleControls() } .onTapGesture { toggleControls() }
.sheet(isPresented: $showsTrackPicker) { .sheet(isPresented: $showsTrackPicker) {
if let model { TrackPickerSheet(model: model) } TrackPickerSheet(model: model)
} }
.confirmationDialog( .confirmationDialog(
Text("player.quality"), Text("player.quality"),
isPresented: $showsQualityChoice, isPresented: $showsQualityChoice,
titleVisibility: .visible titleVisibility: .visible
) { ) {
if let model {
ForEach(PlayerQuality.allCases) { option in ForEach(PlayerQuality.allCases) { option in
Button(qualityChoiceLabel(option, current: model.quality)) { Button(qualityChoiceLabel(option, current: model.quality)) {
Task { await model.select(quality: option) } Task { await model.select(quality: option) }
} }
} }
}
Button("action.cancel", role: .cancel) {} Button("action.cancel", role: .cancel) {}
} }
.onChange(of: showsQualityChoice) { _, isShown in .onChange(of: showsQualityChoice) { _, isShown in
@@ -104,29 +107,8 @@ struct PlayerView: View {
withAnimation(.easeInOut(duration: 0.2)) { showsControls = true } withAnimation(.easeInOut(duration: 0.2)) { showsControls = true }
scheduleControlsHide() scheduleControlsHide()
} }
.task { .task { scheduleControlsHide() }
let model = PlayerModel( .onDisappear { hideTask?.cancel() }
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() }
}
}
} }
// MARK: - Bedienelemente // MARK: - Bedienelemente
@@ -151,14 +133,27 @@ struct PlayerView: View {
private func topBar(for model: PlayerModel) -> some View { private func topBar(for model: PlayerModel) -> some View {
HStack(alignment: .top, spacing: FlixySpacing.regular) { 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 { Button {
playback.dismiss() playback.minimize()
} label: { } label: {
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.font(.title3) .font(.title3)
.padding(FlixySpacing.snug) .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) { VStack(alignment: .leading, spacing: 2) {
Text(model.title) Text(model.title)
@@ -294,7 +289,7 @@ struct PlayerView: View {
if model.hasReachedEnd, model.nextEpisodeRequest() != nil { if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
Button { Button {
if let next = model.nextEpisodeRequest() { if let next = model.nextEpisodeRequest() {
playback.request = next playback.play(next)
} }
} label: { } label: {
Label("player.nextEpisode", systemImage: "forward.end.fill") Label("player.nextEpisode", systemImage: "forward.end.fill")
@@ -362,7 +357,7 @@ struct PlayerView: View {
.font(.flixyBody) .font(.flixyBody)
.foregroundStyle(.white) .foregroundStyle(.white)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button("action.close") { playback.dismiss() } Button("action.close") { playback.close() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
} }
@@ -397,7 +392,7 @@ struct PlayerView: View {
hideTask?.cancel() hideTask?.cancel()
hideTask = Task { hideTask = Task {
try? await Task.sleep(for: .seconds(3.5)) 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 } withAnimation(.easeInOut(duration: 0.3)) { showsControls = false }
} }
} }
+14 -33
View File
@@ -11,11 +11,20 @@ import FlixyVLC
/// `UIView`. /// `UIView`.
struct VideoSurface: View { struct VideoSurface: View {
let engine: (any VideoPlayerEngine)? 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 { var body: some View {
switch engine { switch engine {
case let avEngine as AVPlayerEngine: case is AVPlayerEngine:
AVPlayerSurface(player: avEngine.player) UIViewContainer(view: surface)
case let vlcEngine as VLCPlayerEngine: case let vlcEngine as VLCPlayerEngine:
UIViewContainer(view: vlcEngine.videoView) UIViewContainer(view: vlcEngine.videoView)
default: 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. /// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
/// ///
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet, /// 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. /// 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 { private struct UIViewContainer: UIViewRepresentable {
let view: UIView 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))
}
}