Drei Sachen aus dem Feldtest. Der Griff zum Größerziehen war wirkungslos, und zwar durch meinen Fehler: die Drehung lag auf dem ganzen Überzug statt auf dem Symbol. Bei zwei der vier Ecken kippte damit die gesamte Trefferfläche weg, und man griff ins Leere. Jetzt sitzt der Griff über `overlay(alignment:)`, nur der Pfeil dreht sich, und das Ziehen hat Vorrang vor dem Verschieben der Kachel — sonst gewinnt das Verschieben, das am selben Ort hängt. Senderlogos sind Schriftzüge und fast nie quadratisch. Formatfüllend in einem Quadrat fehlten links und rechts genau die Teile, an denen man den Sender erkennt. Sie bekommen jetzt eine breitere Fläche und werden eingepasst statt beschnitten — `FlixyArtwork` kann beides, und welches gilt, entscheidet der Aufrufer. Und im Player steht bei Live-TV, was gerade läuft. Der Name reist mit dem Einschalten mit, denn nachzuschlagen ist er nicht — ein Sender hat keine Metadaten in der Mediathek. Beim Aufdecken der Bedienelemente wird er nachgeholt, aber nur, wenn die alte Sendung wirklich abgelaufen ist: sonst stünde derselbe Name den ganzen Abend, und das wäre derselbe Fehler, der eben erst im Programm behoben wurde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
6.0 KiB
Swift
172 lines
6.0 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 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,
|
|
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 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)
|
|
}
|
|
}
|
|
}
|