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>
156 lines
5.3 KiB
Swift
156 lines
5.3 KiB
Swift
import Foundation
|
|
import Observation
|
|
import PlexKit
|
|
|
|
/// Was abgespielt werden soll.
|
|
struct PlaybackRequest: Identifiable, Hashable {
|
|
let id = UUID()
|
|
let ratingKey: String
|
|
/// Für die Titelzeile, solange die Metadaten noch geladen werden.
|
|
let title: String
|
|
/// Welche Fassung, wenn ein Titel in mehreren vorliegt.
|
|
let mediaIndex: Int
|
|
/// Ab wo — aus dem gemeldeten Fortschritt.
|
|
let startTime: Duration
|
|
/// Eine bereits feststehende Adresse.
|
|
///
|
|
/// Für Live-TV. Ein Sender liegt nicht in der Mediathek, und der übliche
|
|
/// Weg — Kennung nachschlagen, Datei suchen — findet für ihn nichts.
|
|
/// Daran ist das Einschalten gescheitert: die Adresse war längst
|
|
/// berechnet und wurde weggeworfen.
|
|
var streamURL: URL?
|
|
/// Womit der Tuner nach dem Zusehen wieder freigegeben wird.
|
|
///
|
|
/// Bei vier Tunern ist der fünfte Sender sonst nicht mehr einzuschalten.
|
|
var liveSubscriptionKey: String?
|
|
|
|
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
|
|
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
|
}
|
|
|
|
/// Nimmt Abspielwünsche von überall entgegen.
|
|
///
|
|
/// Ohne diese Stelle bräuchte jeder Bildschirm, von dem aus sich etwas starten
|
|
/// lässt — Aufmacher, Reihe, Detailseite, Suche — seinen eigenen
|
|
/// Vollbild-Übergang. Vier Kopien desselben Ablaufs, die auseinanderdriften.
|
|
@MainActor
|
|
@Observable
|
|
final class PlaybackCoordinator {
|
|
/// 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) {
|
|
play(PlaybackRequest(
|
|
ratingKey: item.ratingKey,
|
|
title: item.playbackTitle,
|
|
mediaIndex: mediaIndex,
|
|
startTime: resuming ? item.resumeTime : .zero
|
|
))
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
extension PlexItem {
|
|
/// Wie der Titel im Player heißt.
|
|
///
|
|
/// Bei Folgen ist der eigene Titel allein nutzlos — „Die erste Folge"
|
|
/// sagt nichts darüber, welche Serie läuft.
|
|
var playbackTitle: String {
|
|
if kind == .episode, let series = grandparentTitle {
|
|
return "\(series) · \(title)"
|
|
}
|
|
return title
|
|
}
|
|
|
|
/// Ab welcher Stelle fortgesetzt wird.
|
|
var resumeTime: Duration {
|
|
guard let viewOffset, viewOffset > 0 else { return .zero }
|
|
return .milliseconds(viewOffset)
|
|
}
|
|
|
|
/// Die Beschriftung unter einer Kachel.
|
|
///
|
|
/// Sie ordnet ein, was darüber steht: zu einer Folge gehört die Serie, zu
|
|
/// einer Staffel ihre Nummer. Die Reihenfolge ist Absicht — wird der
|
|
/// Platz eng, bleibt der Serienname stehen und die Folgennummer fällt weg.
|
|
var cardSubtitle: String? {
|
|
switch kind {
|
|
case .episode:
|
|
var code: String?
|
|
if let season = parentIndex, let episode = index {
|
|
code = String(localized: "episode.short \(season) \(episode)")
|
|
}
|
|
guard let series = grandparentTitle else { return code }
|
|
return code.map { "\(series) · \($0)" } ?? series
|
|
case .season:
|
|
// Ohne die Serie darüber wäre „Staffel 10" beliebig — deshalb
|
|
// steht die Nummer hier und der Serienname als Titel.
|
|
if let index { return String(localized: "season.short \(index)") }
|
|
return parentTitle == nil ? nil : title
|
|
default:
|
|
return year.map(String.init)
|
|
}
|
|
}
|
|
}
|