Die vier erfundenen Stufen weichen der Leiter, die der Server selbst meldet: 64, 96, 208, 320, 720, 1500, 2000, 3000, 4000, 8000, 10000, 12000, 20000 kbps mit den Auflösungen, die Plex dafür vorsieht. Eine eigene Leiter hieße, Stufen anzubieten, die der Transcoder nicht kennt — er rundet dann still auf die nächste, und die Anzeige log. Angeboten wird nur, was zur Quelle passt. Eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu, und die Stufen sähen alle gleich aus. Oben im Blatt steht, was tatsächlich über die Leitung kommt — gemessene Bitrate, Auflösung, verworfene Bilder. Der Unterschied zur gewählten Stufe ist keine Haarspalterei: die eine sagt, was erlaubt ist, die andere, was ankommt. Ruckelt es trotz hoher Stufe, steht die Antwort dort. „Nur Ton" gibt es, aber ehrlich beschriftet: Plex kennt für Videos keinen reinen Tonstrom, also wird die unterste Sprosse geholt und das Bild ausgeblendet. Dazu Doppeltipp links und rechts für zehn Sekunden zurück und vor. Die Reihenfolge der Gesten ist entscheidend — das einfache Tippen verschluckt sonst das doppelte. Die Kachel hängt nicht mehr in vier Ecken fest, sondern klebt links oder rechts und ist in der Höhe frei. Vier Ecken sind schnell gebaut, aber im Weg: steht sie über einem Knopf, bleiben drei Orte, die ebenso stören. Größer wird sie durch Zusammen- und Auseinanderziehen; der Griff ist weg, er saß zwangsläufig auf dem Bild und stritt sich mit dem Verschieben. Und die Begriffe: „Motor" für engine sagt kein deutscher Entwickler. Fachwörter, die im Deutschen englisch bleiben, bleiben jetzt englisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
6.1 KiB
Swift
172 lines
6.1 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
|
|
/// Die zweite Zeile, wenn sie schon feststeht.
|
|
///
|
|
/// Bei Live-TV die laufende Sendung: sie steht nirgends nachzuschlagen —
|
|
/// ein Sender hat keine Metadaten in der Mediathek —, und ohne sie sieht
|
|
/// man im Player nur den Sendernamen und weiß nicht, was läuft.
|
|
var subtitle: String?
|
|
/// Wann die laufende Sendung endet.
|
|
///
|
|
/// Damit im Player nicht den ganzen Abend derselbe Name steht: ein Sender
|
|
/// läuft weiter, die Sendung wechselt. Ohne diese Zeit wüsste niemand,
|
|
/// wann die Angabe falsch wird.
|
|
var subtitleEndsAt: Date?
|
|
/// 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 die Engine, 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,
|
|
programme: String?,
|
|
programmeEndsAt: Date?,
|
|
stream: URL,
|
|
subscriptionKey: String?
|
|
) {
|
|
play(PlaybackRequest(
|
|
ratingKey: channelID,
|
|
title: title,
|
|
subtitle: programme,
|
|
subtitleEndsAt: programmeEndsAt,
|
|
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 die Engine 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)
|
|
}
|
|
}
|
|
}
|