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) } } }