Files
Flaro/Halo/Session/PlaybackCoordinator.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +02:00

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