Start, Suche, Mediathek, Detailseiten und Downloads arbeiten jetzt mit `MediaItem` und dem `MediaBackend`-Vertrag. Damit sind sie fertig für einen zweiten Server, ohne dass an ihnen noch etwas zu tun wäre. Dabei sind ein paar Namen ehrlicher geworden: aus `grandparentTitle` wurde `seriesTitle`, aus `viewedLeafCount` `playedEpisodeCount`. Und `TagStrip` nahm bisher Plex-Marken mit Kennung und Rolle entgegen, um davon nur den Namen anzuzeigen — Genres sind jetzt schlicht Zeichenketten. `Arrangement` ist aus PlexKit heraus und generisch: dieselbe Rechnung gilt für jede Liste, die jemand selbst zusammenstellt. Phase 3 baut die konfigurierbare Seitenleiste darauf, statt sie ein zweites Mal — und ein zweites Mal falsch — zu schreiben. Die Fehlerübersetzung gibt es nur noch einmal. Es waren zwei, und sie waren bereits auseinandergelaufen: derselbe Fehler bekam je nach Bildschirm eine andere Erklärung. Das Nachladen der Mediathek bleibt unangetastet: eine lange Liste, die fünf Einträge vor dem Ende nachlädt. `MediaPage` heißt zwar Seite, ist aber eine technische Portion — Blättern mit „weiter zu Seite 2" gehört nicht in eine Mediathek, in der man stöbert. Der Kommentar dort sagt das jetzt ausdrücklich, damit es niemand später „aufräumt". Player, Live-TV und die Download-Engine sprechen noch unmittelbar mit Plex. Sie folgen als Nächstes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
5.9 KiB
Swift
166 lines
5.9 KiB
Swift
import Foundation
|
|
import Observation
|
|
import MediaKit
|
|
|
|
/// 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: MediaItem, mediaIndex: Int = 0, resuming: Bool = true) {
|
|
play(PlaybackRequest(
|
|
ratingKey: item.id,
|
|
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 MediaItem {
|
|
/// 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 = seriesTitle {
|
|
return "\(series) · \(title)"
|
|
}
|
|
return title
|
|
}
|
|
|
|
/// 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 = seriesTitle 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)
|
|
}
|
|
}
|
|
}
|