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>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.scarriffleservices.halo</string>
</array>
</dict>
</plist>
+72
View File
@@ -0,0 +1,72 @@
import SwiftUI
import HaloDesign
import HaloPlayback
import MediaKit
import JellyfinKit
/// Die App auf dem Fernseher.
///
/// **Eine eigene Wurzel statt eines Zweigs in `HaloApp`.** Der Unterschied
/// zwischen Telefon und Fernseher ist keine Feinheit der Darstellung, sondern
/// die Bedienart: kein Finger, keine Grössenklasse, keine Werkzeugleiste, kein
/// Ziehen zum Aktualisieren, keine halbhohen Sheets — und dafür ein Fokus, den
/// es auf iOS gar nicht gibt. Ein `#if os(tvOS)` in der bestehenden Wurzel
/// wäre kein Zweig, sondern ein zweiter Rumpf in derselben Datei, und der
/// nächste, der die iOS-Seite ändert, bräche die andere, ohne es zu merken.
///
/// **Die Modelle dagegen werden geteilt**, nicht kopiert: `Halo/Session/`
/// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft
/// zwischen Modell und Ansicht, nicht zwischen den Plattformen.
///
/// **Kein SwiftData und kein Download-Koordinator.** Der `ModelContainer` der
/// iOS-App trägt ausschliesslich Downloads, und die gibt es hier nicht — damit
/// fällt beides weg.
@main
struct HaloTVApp: App {
@State private var session: HaloSession
@State private var theme = HaloTheme()
@State private var playback = PlaybackCoordinator()
@State private var playbackSettings = PlaybackSettings()
@State private var languages = LanguagePreferences()
@State private var quickPlay = QuickPlay()
@State private var subtitleAppearance = SubtitleAppearance()
@State private var homeLayout = HomeLayoutSettings()
@State private var diagnostics = PlaybackDiagnostics()
/// Beobachtet die Verbindung. Lebt hier, damit der Beobachter einmal läuft
/// statt bei jedem Öffnen des Players neu zu starten.
///
/// Ein Apple TV hängt fast immer am Kabel oder an festem WLAN — aber
/// „fast" ist kein Grund, die Entscheidungen des Players zu raten.
@State private var network = NetworkConditions()
init() {
_session = State(initialValue: HaloSession(
jellyfinIdentity: JellyfinIdentity.current(
client: Branding.appName,
version: Branding.version
),
tokenStore: KeychainTokenStore(service: Branding.keychainService)
))
}
var body: some Scene {
WindowGroup {
TVRootView()
.environment(session)
.environment(theme)
.environment(playback)
.environment(playbackSettings)
.environment(languages)
.environment(quickPlay)
.environment(subtitleAppearance)
.environment(homeLayout)
.environment(diagnostics)
.environment(network)
.haloAccent(theme)
.tint(theme.accentColor)
// Ein Fernseher ist immer dunkel. Der Schalter in der
// Info.plist gilt fürs System, diese Zeile für die Vorschauen.
.preferredColorScheme(.dark)
}
}
}
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Halo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.scarriffleservices.halo.topshelf</string>
<key>CFBundleURLSchemes</key>
<array>
<string>halo</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Halo benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen.</string>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>
@@ -0,0 +1,13 @@
{
"images": [
{
"idiom": "tv",
"filename": "back.png",
"scale": "1x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,14 @@
{
"layers": [
{
"filename": "Front.imagestacklayer"
},
{
"filename": "Back.imagestacklayer"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,13 @@
{
"images": [
{
"idiom": "tv",
"filename": "front.png",
"scale": "1x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"idiom": "tv",
"filename": "back.png",
"scale": "1x"
},
{
"idiom": "tv",
"filename": "back@2x.png",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,14 @@
{
"layers": [
{
"filename": "Front.imagestacklayer"
},
{
"filename": "Back.imagestacklayer"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"idiom": "tv",
"filename": "front.png",
"scale": "1x"
},
{
"idiom": "tv",
"filename": "front@2x.png",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,32 @@
{
"assets": [
{
"filename": "App Icon.imagestack",
"idiom": "tv",
"role": "primary-app-icon",
"size": "400x240"
},
{
"filename": "App Icon - App Store.imagestack",
"idiom": "tv",
"role": "primary-app-icon",
"size": "1280x768"
},
{
"filename": "Top Shelf Image.imageset",
"idiom": "tv",
"role": "top-shelf-image",
"size": "1920x720"
},
{
"filename": "Top Shelf Image Wide.imageset",
"idiom": "tv",
"role": "top-shelf-image-wide",
"size": "2320x720"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"idiom": "tv",
"filename": "topshelfwide.png",
"scale": "1x"
},
{
"idiom": "tv",
"filename": "topshelfwide@2x.png",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"idiom": "tv",
"filename": "topshelf.png",
"scale": "1x"
},
{
"idiom": "tv",
"filename": "topshelf@2x.png",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
+49
View File
@@ -0,0 +1,49 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Die Detailseite auf dem Fernseher.
///
/// Fassung eins zeigt, worum es geht, und lässt es abspielen. Staffeln und
/// Folgen kommen im nächsten Schritt — bis dahin führt eine Serie über
/// `QuickPlay` zur nächsten ungesehenen Folge, genau wie die Kachel.
struct TVDetailView: View {
let route: MediaRoute
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
@State private var state: LoadState<MediaItem> = .loading
var body: some View {
Group {
switch state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let meldung):
VStack(spacing: HaloSpacing.regular) {
Text(meldung).font(.haloBody).foregroundStyle(.white)
Button("action.retry") { Task { await lade() } }
}
case .loaded(let item):
TVHeroSection(
item: item,
onPlay: {
quickPlay.start(item, backend: session.backend, coordinator: playback)
},
onDetails: {}
)
.ignoresSafeArea()
}
}
.task { await lade() }
}
private func lade() async {
guard let backend = session.backend else { return }
state = await LoadState.load(keepingPrevious: state) {
try await backend.item(id: route.ratingKey)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Der Aufmacher oben auf der Startseite des Fernsehers.
///
/// Derselbe `HeroBanner` wie am Telefon — das Bauteil kennt keine Plattform,
/// es setzt Bild, Verlauf und Text. Anders sind nur die Knöpfe: hier sind es
/// Fokusziele, und sie müssen gross genug sein, dass man aus drei Metern
/// sieht, welcher gerade dran ist.
struct TVHeroSection: View {
let item: MediaItem
let onPlay: () -> Void
let onDetails: () -> Void
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
var body: some View {
HeroBanner(
title: item.rowTitle,
metadata: metadaten,
summary: item.summary,
artworkURLs: session.backend.map {
HeroArtwork.backdropURLs(for: item, backend: $0, compact: false)
} ?? [],
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
aspectRatio: HaloAspectRatio.hero,
// Der Aufmacher nimmt die Höhe, die das Karussell ihm gibt.
fillsAvailableSpace: true
) {
Button(action: onPlay) {
Label(abspielTitel, systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.tint(accent)
Button(action: onDetails) {
Label("hero.details", systemImage: "info.circle")
}
.buttonStyle(.bordered)
}
}
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
private var abspielTitel: LocalizedStringKey {
item.watchProgress == nil ? "hero.play" : "hero.resume"
}
private var metadaten: String? {
var teile: [String] = []
if item.kind == .episode, let staffel = item.parentIndex, let folge = item.index {
// Ausgeschrieben: der Aufmacher am Fernseher ist 1760 Punkte
// breit, und gelesen wird er aus drei Metern.
teile.append(String(localized: "episode.long \(staffel) \(folge)"))
}
if let jahr = item.year { teile.append(String(jahr)) }
if let dauer = item.duration {
teile.append(Duration.milliseconds(dauer).runtimeLabel)
}
if let freigabe = item.contentRating { teile.append(freigabe) }
return teile.isEmpty ? nil : teile.joined(separator: " · ")
}
}
+148
View File
@@ -0,0 +1,148 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Die Startseite auf dem Fernseher.
///
/// **Dasselbe Modell wie am Telefon.** `HomeFeed` lädt, ordnet und lost den
/// Aufmacher aus; hier steht nur, wie das aussieht. Der Unterschied ist die
/// Bedienart, nicht der Inhalt.
struct TVHomeView: View {
/// Wohin ein Link aus dem Regal führen soll.
///
/// **Als Bindung von aussen**, nicht als eigener Zustand: das Regal
/// erreicht die Wurzel, der Navigationsstapel liegt hier. Einen zweiten
/// Stapel eine Ebene höher anzulegen, nur um ein Ziel durchzureichen,
/// hiesse zwei Stapel gleich zu halten.
@Binding var deepLinkTarget: MediaRoute?
@Environment(HaloSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
@State private var feed = HomeFeed()
@State private var pfad = NavigationPath()
/// Welcher Titel gerade oben gross steht.
///
/// **Der Fokus bestimmt ihn, nicht eine Uhr.** Am Telefon wechselt der
/// Aufmacher alle sieben Sekunden von selbst — dort blättert man ihn auch
/// mit dem Daumen. Vor einem Fernseher wäre ein Bild, das sich von allein
/// austauscht, während man es ansieht, eine Zumutung: man verliert den
/// Titel, den man gerade lesen wollte.
@State private var aufmacher: MediaItem?
var body: some View {
NavigationStack(path: $pfad) {
inhalt
.navigationDestination(for: MediaRoute.self) { route in
TVDetailView(route: route)
}
}
.task(id: session.serverName) { await feed.load(from: session.backend) }
.onChange(of: deepLinkTarget) { _, neu in
guard let neu else { return }
pfad.append(neu)
deepLinkTarget = nil
}
}
@ViewBuilder
private var inhalt: some View {
switch feed.state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let meldung):
VStack(spacing: HaloSpacing.regular) {
Text(meldung).font(.haloBody).foregroundStyle(.white)
Button("action.retry") {
Task { await feed.load(from: session.backend) }
}
}
case .loaded:
let reihen = feed.rows(for: layout)
if reihen.isEmpty && session.libraries.isEmpty {
Text("home.empty.title").font(.haloRowHeader).foregroundStyle(.white)
} else {
seite(reihen: reihen)
}
}
}
private func seite(reihen: [MediaRowGroup]) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
if let gezeigt = aufmacher ?? feed.heroItems(for: layout).first {
TVHeroSection(
item: gezeigt,
onPlay: { spiele(gezeigt) },
onDetails: { zeige(gezeigt) }
)
.frame(height: 720)
}
ForEach(reihen) { reihe in
// **Der Fokus setzt den Aufmacher.** Wer durch „Zuletzt
// hinzugefügt" wandert, sieht oben, was er gerade
// anschaut — ohne irgendwo hindrücken zu müssen.
TVMediaRow(
row: reihe,
onSelect: { spiele($0) },
onFocus: { aufmacher = $0 }
)
}
}
.padding(.bottom, HaloSpacing.screenMargin)
}
.scrollIndicators(.never)
// **Bildfüllend, und der Rand steckt in `screenMargin`.** Der
// Aufmacher muss bis an die Bildschirmkante reichen; ein Bild, das
// achtzig Punkte davor aufhört, sieht nach Fehler aus.
.ignoresSafeArea()
// Als Überlagerung und nicht als letzte Zeile der Liste: dorthin
// müsste man erst an allen Reihen vorbeiscrollen, und wer nachsieht,
// warum das Regal leer ist, soll es sofort sehen.
.overlay(alignment: .bottom) { regalStatus }
}
/// Was das Regal über der App zuletzt bekommen hat.
///
/// **Nur im Entwicklerbau.** Es gehört nicht in die fertige App — aber
/// solange das Regal leer bleibt, sieht man von aussen nicht, woran es
/// liegt: fehlende App-Gruppe, fehlende Datei und ein System, das nicht
/// fragt, sehen alle gleich aus. Diese Zeile unterscheidet sie.
@ViewBuilder
private var regalStatus: some View {
#if DEBUG
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
Text("topShelf.title")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Text(TopShelfWriter.status)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
.padding(.horizontal, HaloSpacing.screenMargin)
.padding(.bottom, HaloSpacing.regular)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black.opacity(0.6))
#endif
}
// MARK: - Was ein Druck auslöst
/// **Über QuickPlay, nicht unmittelbar.** Steht in der Reihe eine Serie —
/// und Jellyfins „Zuletzt hinzugefügt" liefert neue Folgen als ihre Serie
/// —, schickte der direkte Weg deren eigene Kennung an den Player, und der
/// scheiterte mit „keine abspielbare Datei". Eine Serie hat keine
/// Fassungen.
private func spiele(_ item: MediaItem) {
quickPlay.start(item, backend: session.backend, coordinator: playback)
}
private func zeige(_ item: MediaItem) {
pfad.append(MediaRoute(ratingKey: item.id, title: item.title))
}
}
+73
View File
@@ -0,0 +1,73 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Eine waagerechte Reihe von Titeln auf dem Fernseher.
///
/// Dieselben Daten wie `MediaRow` am Telefon, aber eine andere Bedienung: es
/// gibt keinen Finger, der die Reihe schiebt, sondern einen Fokus, der von
/// Karte zu Karte springt und die Reihe hinter sich herzieht.
struct TVMediaRow: View {
let row: MediaRowGroup
let onSelect: (MediaItem) -> Void
/// Wer gerade dran ist — damit der Aufmacher oben mitwandert.
var onFocus: (MediaItem) -> Void = { _ in }
@Environment(HaloSession.self) private var session
/// Breit genug, dass die übernächste Karte angeschnitten ist — das zeigt,
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
private var breite: CGFloat { row.isContinueWatching ? 500 : 280 }
var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text(row.title)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.screenMargin)
ScrollView(.horizontal) {
LazyHStack(spacing: HaloSpacing.regular) {
ForEach(row.items) { item in
karte(for: item).frame(width: breite)
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
// **Platz zum Anheben.** Eine fokussierte Karte wächst über
// ihren Rahmen hinaus, und eine ScrollView beschneidet am
// Rand. Ohne Polster und ohne das abgeschaltete Beschneiden
// fehlt der obersten Reihe die Anhebung — und zwar nur ihr,
// was wie ein Zufall aussieht statt wie ein Fehler.
.padding(.vertical, HaloSpacing.regular)
}
.scrollClipDisabled()
.scrollIndicators(.never)
}
}
/// Szenenbild beim Weiterschauen, sonst das Plakat.
@ViewBuilder
private func karte(for item: MediaItem) -> some View {
if row.isContinueWatching {
TVLandscapeCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(
path: item.rowArtworkPath, width: 880, height: 496
),
progress: item.watchProgress,
onFocusChange: { if $0 { onFocus(item) } }
) { onSelect(item) }
} else {
TVPosterCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(
path: item.thumb, width: 560, height: 840
),
progress: item.watchProgress,
onFocusChange: { if $0 { onFocus(item) } }
) { onSelect(item) }
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Eine Mediathek als Raster auf dem Fernseher.
///
/// **Nachgeladen wird am Fokus, nicht am Erscheinen.** Am Telefon hängt es
/// daran, dass eine Kachel sichtbar wird — bei sechzig Kacheln auf einem
/// breiten Raster erscheinen die aber alle beim ersten Zeichnen, und das
/// Nachladen liefe sofort los statt beim Blättern. Der Fokus steht dagegen
/// immer nur an einer Stelle, und genau die ist die Frage.
struct TVLibraryView: View {
let libraryID: String
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
@State private var feed: LibraryFeed?
@FocusState private var fokus: String?
/// Sechs Spalten auf 1760 Punkten Nutzbreite: rund 250 Punkte je Plakat,
/// aus drei Metern noch erkennbar.
private static let spalten = 6
private var raster: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: HaloSpacing.section), count: Self.spalten)
}
var body: some View {
Group {
if let feed {
inhalt(feed: feed)
} else {
ProgressView()
}
}
.task {
guard feed == nil,
let mediathek = session.libraries.first(where: { $0.id == libraryID })
else { return }
let frisch = LibraryFeed(library: mediathek)
feed = frisch
await frisch.reload(from: session.backend)
}
}
@ViewBuilder
private func inhalt(feed: LibraryFeed) -> some View {
switch feed.state {
case .loading where feed.items.isEmpty:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let meldung) where feed.items.isEmpty:
VStack(spacing: HaloSpacing.regular) {
Text(meldung).font(.haloBody).foregroundStyle(.white)
Button("action.retry") { Task { await feed.reload(from: session.backend) } }
}
default:
raster(feed: feed)
}
}
private func raster(feed: LibraryFeed) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.section) {
Text(feed.library.title)
.font(.haloHero)
.foregroundStyle(.white)
.padding(.horizontal, HaloSpacing.screenMargin)
LazyVGrid(columns: raster, spacing: HaloSpacing.section) {
ForEach(Array(feed.items.enumerated()), id: \.element.id) { stelle, item in
TVPosterCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(
path: item.posterArtworkPath, width: 560, height: 840
),
progress: item.watchProgress
) {
// **Über QuickPlay, nicht unmittelbar.** Steht im
// Raster eine Serie, hat die keine Fassungen; der
// direkte Weg schickte ihre eigene Kennung an den
// Player und scheiterte mit „keine abspielbare
// Datei". QuickPlay löst stattdessen die nächste
// ungesehene Folge auf.
//
// Ein Kontextmenü wie am Telefon gibt es hier
// nicht: langes Drücken ist auf der Fernbedienung
// keine verlässliche Geste.
quickPlay.start(item, backend: session.backend, coordinator: playback)
}
.focused($fokus, equals: item.id)
.onChange(of: fokus) { _, neu in
guard neu == item.id,
feed.shouldLoadMore(at: stelle, columns: Self.spalten)
else { return }
Task { await feed.loadMore(from: session.backend) }
}
}
// **Die Platzhalter bleiben.** Am Fernseher sind sie
// wichtiger als am Telefon: hört das Raster einfach auf,
// läuft der Fokus gegen eine unsichtbare Wand. Sie dürfen
// ihn aber nicht annehmen, sonst landet er auf einem
// grauen Kasten.
ForEach(0..<min(feed.pendingCount, Self.spalten * 2), id: \.self) { _ in
PlaceholderCard()
.focusable(false)
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
if feed.loadMoreFailed {
Button("library.loadMore") {
Task { await feed.loadMore(from: session.backend) }
}
.padding(.horizontal, HaloSpacing.screenMargin)
}
}
.padding(.vertical, HaloSpacing.screenMargin)
}
.scrollClipDisabled()
.scrollIndicators(.never)
// Siehe `HaloSpacing.screenMargin`: bildfüllend, Rand im Wert.
.ignoresSafeArea()
}
}
@@ -0,0 +1,216 @@
import AVKit
import UIKit
import HaloPlayback
import MediaKit
/// Führt Apples Player — und hält ihn über einen Neuaufbau hinweg am Leben.
///
/// **Das Problem, um das es hier geht.** Unsere Ton- und Untertitelwahl geht
/// als `audioStreamIndex` an Jellyfin und verlangt bei einem Transcode einen
/// **neuen Stream**. Apples eingebauter Spurwähler zeigt dagegen die
/// `AVMediaSelectionGroup`s des **laufenden** Streams — beim Transcode genau
/// eine. Er ist also die richtige Oberfläche für die falsche Liste.
///
/// Deshalb bekommt die Transportleiste unsere eigenen Menüs über
/// `transportBarCustomMenuItems`. Nicht `customInfoViewControllers`: die Wahl
/// der Tonspur ist ein Bedienelement der Wiedergabe, kein Begleitmaterial —
/// Apples eigene Beispiele dafür sind Bonusinhalte und Verwandtes, und dort
/// erwartet niemand „Sprache".
@MainActor
final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDelegate {
let controller = AVPlayerViewController()
private let onDismiss: () -> Void
private let appearance: SubtitleAppearance
private var letzteAufnahme: TVPlayerSnapshot?
/// Wacht darüber, wann es etwas zu beschriften gibt.
///
/// **Nicht beim Austausch des Players.** In dem Moment ist `currentItem`
/// noch `nil` — das Item entsteht erst, wenn das Asset geladen ist, und
/// bis dahin zeigt Apples Titelzeile, was es selbst aus der Datei liest:
/// „Unbekannt". Also wird gewartet, bis das Item wirklich da ist.
private var itemBeobachter: NSKeyValueObservation?
/// Was drinstehen soll, sobald es ein Item gibt.
private var beschriftung: (titel: String, untertitel: String?)?
/// Die Abdeckung für das schwarze Bild während eines Neuaufbaus.
private lazy var abdeckung = TVRebuildOverlay()
private var abdeckungsAufgabe: Task<Void, Never>?
init(appearance: SubtitleAppearance, onDismiss: @escaping () -> Void) {
self.appearance = appearance
self.onDismiss = onDismiss
super.init()
controller.delegate = self
controller.videoGravity = .resizeAspect
// Nie auf dem Fernseher — ein Apple TV hat kein zweites Fenster.
controller.allowsPictureInPicturePlayback = false
controller.transportBarIncludesTitleView = true
// **Apples eigenen Untertitelknopf abschalten.**
//
// Sonst stehen zwei Sprechblasen nebeneinander, und welche was tut,
// sieht man erst nach dem Drücken. Schlimmer noch: Apples Menü
// verstellt die **Systemeinstellung** für Untertitel, während unsere
// Engine `AVTextStyleRule`s aus `SubtitleAppearance` setzt — zwei
// Stilregler, von denen nur einer wirkt.
//
// `nil` heisst „alle Sprachen anbieten", eine leere Liste also keine.
// Das ist der einzige dokumentierte Hebel dafür; die Auswahl von Hand
// (`AVPlayerEngine`) bleibt davon unberührt, hier geht es nur um das,
// was die Leiste anbietet.
controller.allowedSubtitleOptionLanguages = []
// **Hinter den Bedienelementen und ohne Fokus.** `contentOverlayView`
// liegt über dem Bild, aber unter Apples Leiste, und nimmt keine
// Eingaben an. `customOverlayViewController` täte beides nicht: der
// ersetzt die ganze Überlagerung und zieht den Fokus an sich — damit
// wäre die Fernbedienung während des Umschaltens tot.
if let ebene = controller.contentOverlayView {
abdeckung.translatesAutoresizingMaskIntoConstraints = false
ebene.addSubview(abdeckung)
NSLayoutConstraint.activate([
abdeckung.topAnchor.constraint(equalTo: ebene.topAnchor),
abdeckung.bottomAnchor.constraint(equalTo: ebene.bottomAnchor),
abdeckung.leadingAnchor.constraint(equalTo: ebene.leadingAnchor),
abdeckung.trailingAnchor.constraint(equalTo: ebene.trailingAnchor),
])
}
}
// MARK: - Nachführen
func apply(_ aufnahme: TVPlayerSnapshot, model: PlayerModel) {
defer { letzteAufnahme = aufnahme }
// **Den Player nur austauschen, wenn es ein anderer ist.** `restart()`
// baut eine neue Engine mit einem neuen `AVPlayer`; genau dann — und
// nur dann — wird das Bild schwarz.
if aufnahme.playerIdentity != letzteAufnahme?.playerIdentity {
controller.player = model.systemPlayer
beobachteItem()
}
beschriftung = (aufnahme.title, aufnahme.subtitle)
beschrifte()
zeigeAbdeckung(aufnahme.isRebuilding)
controller.transportBarCustomMenuItems = TVTrackMenuBuilder.items(
for: aufnahme,
appearance: appearance,
onAudio: { [weak model] index in
guard let model, let stream = model.audioStreams.first(where: { $0.index == index })
else { return }
Task { await model.selectAudioStream(stream) }
},
onSubtitle: { [weak model] index in
guard let model else { return }
let stream = index.flatMap { i in
model.subtitleStreams.first { $0.index == i }
}
Task { await model.selectSubtitleStream(stream) }
}
)
controller.contextualActions = TVTrackMenuBuilder.contextualActions(
for: aufnahme,
onSkip: { [weak model] in model?.skipActiveMarker() }
)
}
private func beobachteItem() {
itemBeobachter = controller.player?.observe(\.currentItem, options: [.initial, .new]) {
[weak self] _, _ in
MainActor.assumeIsolated { self?.beschrifte() }
}
}
/// Titel und Untertitel in Apples Titelzeile.
///
/// Über `externalMetadata` am `AVPlayerItem`, weil die Transportleiste
/// genau dort nachsieht. Auf iOS zeichnet unsere eigene Kopfzeile das;
/// hier gäbe es sonst gar keine Auskunft darüber, was läuft — ausser dem,
/// was AVKit selbst aus der Datei liest, und das ist bei einem
/// Transcode-Stream „Unbekannt".
private func beschrifte() {
guard let item = controller.player?.currentItem,
let beschriftung
else { return }
var einträge: [AVMetadataItem] = [eintrag(.commonIdentifierTitle, beschriftung.titel)]
if let untertitel = beschriftung.untertitel {
einträge.append(eintrag(.iTunesMetadataTrackSubTitle, untertitel))
}
item.externalMetadata = einträge
}
private func eintrag(_ kennung: AVMetadataIdentifier, _ wert: String) -> AVMetadataItem {
let eintrag = AVMutableMetadataItem()
eintrag.identifier = kennung
eintrag.value = wert as NSString
eintrag.extendedLanguageTag = "und"
return eintrag
}
/// Blendet die Abdeckung ein — aber erst nach einer Viertelsekunde.
///
/// Der häufige Fall (unverändert gespielt, unter fünfzig Millisekunden)
/// soll nicht aufblitzen. Wer nur umschaltet, sieht dann gar nichts.
private func zeigeAbdeckung(_ sichtbar: Bool) {
abdeckungsAufgabe?.cancel()
guard sichtbar else {
abdeckung.setVisible(false)
return
}
abdeckungsAufgabe = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
self?.abdeckung.setVisible(true)
}
}
// MARK: - Der Menü-Knopf der Fernbedienung
/// Fängt den Menü-Knopf ab, statt in SwiftUI `onExitCommand` zu raten.
func playerViewControllerShouldDismiss(_ controller: AVPlayerViewController) -> Bool {
onDismiss()
return true
}
}
/// Die Abdeckung, die das schwarze Bild während eines Neuaufbaus zudeckt.
///
/// Ohne sie sieht man Sekunden lang nichts und hält es für einen Absturz —
/// dabei ist es die angekündigte Folge einer Spurwahl.
@MainActor
final class TVRebuildOverlay: UIView {
private let kreisel = UIActivityIndicatorView(style: .large)
private let zeile = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.black.withAlphaComponent(0.75)
isUserInteractionEnabled = false
alpha = 0
kreisel.color = .white
kreisel.startAnimating()
zeile.text = String(localized: "player.rebuilding")
zeile.textColor = .white
zeile.font = .preferredFont(forTextStyle: .headline)
let stapel = UIStackView(arrangedSubviews: [kreisel, zeile])
stapel.axis = .vertical
stapel.alignment = .center
stapel.spacing = 24
stapel.translatesAutoresizingMaskIntoConstraints = false
addSubview(stapel)
NSLayoutConstraint.activate([
stapel.centerXAnchor.constraint(equalTo: centerXAnchor),
stapel.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("nicht aus einem Storyboard") }
func setVisible(_ sichtbar: Bool) {
UIView.animate(withDuration: 0.2) { self.alpha = sichtbar ? 1 : 0 }
}
}
+89
View File
@@ -0,0 +1,89 @@
import AVKit
import SwiftUI
import HaloPlayback
import MediaKit
/// Hängt Apples Player in den SwiftUI-Baum.
///
/// **Wem was gehört.** Der `AVPlayerViewController` gehört dem Coordinator,
/// nicht der SwiftUI-Struktur — die wird bei jedem Neuzeichnen neu gebaut —
/// und nicht dem `PlayerModel`, das frei von UIKit bleiben soll. Das Modell
/// gibt nur seinen `AVPlayer` heraus, und das ist AVFoundation.
@MainActor
struct TVPlayerHost: UIViewControllerRepresentable {
let model: PlayerModel
/// Eine Momentaufnahme dessen, was den Controller betrifft.
///
/// **Als Wert, nicht als Verweis.** SwiftUI ruft `updateUIViewController`
/// nur, wenn sich am Repräsentanten etwas geändert hat. Stünde hier allein
/// das Modell, änderte sich nie etwas: es ist eine Klasse, und ihre
/// Kennung bleibt über jeden Spurwechsel hinweg dieselbe. Der Wert wird im
/// Rumpf der umgebenden Ansicht gebildet — dort und nur dort entsteht die
/// Beobachtungsbeziehung zu `@Observable`.
let snapshot: TVPlayerSnapshot
/// Der Untertitelstil — er steht im selben Menü wie die Spurwahl.
let appearance: SubtitleAppearance
let onDismiss: () -> Void
func makeUIViewController(context: Context) -> AVPlayerViewController {
context.coordinator.controller
}
func updateUIViewController(_ controller: AVPlayerViewController, context: Context) {
context.coordinator.apply(snapshot, model: model)
}
func makeCoordinator() -> TVPlayerCoordinator {
TVPlayerCoordinator(appearance: appearance, onDismiss: onDismiss)
}
}
/// Was der Controller von der Wiedergabe wissen muss — und mehr nicht.
struct TVPlayerSnapshot: Equatable {
/// Wechselt bei jedem Neuaufbau. Daran hängt der Austausch des Players.
let playerIdentity: ObjectIdentifier?
let audio: [TrackMenu.Entry]
let subtitles: [TrackMenu.Entry]
/// Läuft gerade ein Neuaufbau? Dann ist das Bild schwarz und das Menü
/// gesperrt.
let isRebuilding: Bool
let title: String
let subtitle: String?
/// Der Zeitbereich einer Marke — für „Intro überspringen".
let markerLabel: String?
/// Der eingestellte Untertitelstil.
///
/// **Gehört in die Momentaufnahme, obwohl der Player ihn nicht braucht.**
/// Er steht im selben Menü wie die Spurwahl, und ohne ihn hier merkte
/// SwiftUI nicht, dass sich etwas geändert hat — die Haken blieben beim
/// alten Wert stehen, während die Untertitel längst anders aussähen.
let subtitleStyle: SubtitleStyle
/// `@MainActor`, weil das Modell es ist: gelesen wird hier zwölfmal aus
/// ihm, und zwar im Rumpf einer View — also ohnehin auf dem Hauptakteur.
@MainActor
init(_ model: PlayerModel, appearance: SubtitleAppearance, offLabel: String) {
subtitleStyle = appearance.style
playerIdentity = model.systemPlayer.map(ObjectIdentifier.init)
let direkt: Bool = if case .directPlay = model.decision?.method { true } else { false }
audio = TrackMenu.audio(
from: model.audioStreams,
selected: model.selectedAudioStream,
isDirectPlay: direkt,
playerTrackCount: model.audioTracks.count
)
subtitles = TrackMenu.subtitles(
from: model.subtitleStreams,
selected: model.selectedSubtitleStream,
isDirectPlay: direkt,
playerTrackLanguages: model.subtitleTracks.map(\.languageCode),
offLabel: offLabel
)
isRebuilding = model.isChangingQuality
title = model.title
subtitle = model.subtitle
markerLabel = model.activeMarker.map {
String(localized: $0.isIntro ? "player.skipIntro" : "player.skipCredits")
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import SwiftUI
import HaloDesign
import HaloPlayback
import MediaKit
/// Der Player auf dem Fernseher.
///
/// **Das Modell lebt hier und stirbt hier.** Auf dem Telefon hält `RootView`
/// es fest, damit die Wiedergabe den Wechsel zwischen Kachel und Vollbild
/// überlebt. Ohne Mini-Player gibt es diesen Wechsel nicht, und ein
/// hochgehaltenes Modell hielte nur eine tote Engine, einen laufenden
/// Transcoder und eine offene Jellyfin-Sitzung am Leben, nachdem der Player
/// längst weg ist.
struct TVPlayerScreen: View {
let request: PlaybackRequest
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(LanguagePreferences.self) private var languages
@Environment(SubtitleAppearance.self) private var subtitleAppearance
@Environment(NetworkConditions.self) private var network
@Environment(PlaybackDiagnostics.self) private var diagnostics
@State private var model: PlayerModel?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let model {
// **Die Momentaufnahme entsteht hier, im Rumpf einer View.**
// Nur so entsteht die Beobachtungsbeziehung zu `@Observable`
// — ein Repräsentant, der bloss das Modell hält, bekäme nie
// ein `updateUIViewController`.
TVPlayerHost(
model: model,
snapshot: TVPlayerSnapshot(
model,
appearance: subtitleAppearance,
offLabel: String(localized: "player.subtitlesOff")
),
appearance: subtitleAppearance,
onDismiss: { playback.close() }
)
.ignoresSafeArea()
} else {
ProgressView()
}
}
// **Der Stil wirkt sofort, ohne Neuaufbau.**
//
// Auf `style` und nicht auf die Klasse: ein `@Observable` meldet jede
// Berührung, und dann liefe das hier auch, wenn sich nichts geändert
// hat. `SubtitleStyle` ist vergleichbar und feuert nur bei einer
// echten Änderung.
//
// Dass es hier steht und nicht im Coordinator, hat denselben Grund
// wie die Momentaufnahme: nur im Rumpf einer View entsteht die
// Beobachtungsbeziehung.
.onChange(of: subtitleAppearance.style) { _, neu in
model?.engine?.applySubtitleStyle(neu)
}
// **Der Menü-Knopf wirkt auch, solange noch nichts läuft.**
//
// `AVPlayerViewController` fängt ihn selbst ab — aber erst, wenn er
// einen Player mit einem Item hat. Solange der Stream noch
// ausgehandelt wird oder der Server erst anfängt zu rechnen, gibt es
// den nicht, und man sass fest: schwarzes Bild, Kreisel, kein Weg
// zurück. Hier liegt der Griff eine Ebene darüber, wo es ihn vom
// ersten Bild an gibt.
//
// Doppelt ist er nicht: greift Apples Player zu, kommt der Befehl
// hier gar nicht mehr an.
.onExitCommand { playback.close() }
.task {
guard model == nil else { return }
let frisch = PlayerModel(
request: request,
backend: session.backend,
liveTV: nil, // Kein Live-TV in Fassung eins.
downloads: nil, // Nie auf dem Fernseher.
diagnostics: diagnostics,
policy: settings.policy(for: network),
settings: settings,
languages: languages,
subtitles: subtitleAppearance,
isLocalConnection: session.isLocalConnection,
connectionDescription: session.connectionDescription,
unavailableMessage: session.playbackUnavailableMessage
)
model = frisch
await frisch.start()
}
// Beim Verschwinden wirklich Schluss: Engine abräumen, Fortschritt
// melden, die Jellyfin-Sitzung schliessen. Ohne das liefe der
// Transcoder auf dem Server weiter, obwohl niemand mehr zusieht.
.onDisappear { [model] in Task { await model?.stop() } }
}
}
@@ -0,0 +1,137 @@
import AVKit
import UIKit
import HaloPlayback
/// Baut aus unseren Spurlisten die Menüs für Apples Transportleiste.
///
/// Die reine Abbildung steht in `TrackMenu` und ist dort geprüft; hier geht es
/// nur noch um UIKit.
@MainActor
enum TVTrackMenuBuilder {
/// - Parameter appearance: Der Untertitelstil. Er steht **in** diesem
/// Menü und nicht daneben: vorher gab es zwei Knöpfe mit derselben
/// Sprechblase — einen für die Spur, einen für den Stil —, und welcher
/// was tat, sah man erst nach dem Drücken.
static func items(
for aufnahme: TVPlayerSnapshot,
appearance: SubtitleAppearance,
onAudio: @escaping (Int?) -> Void,
onSubtitle: @escaping (Int?) -> Void
) -> [UIMenuElement] {
var menüs: [UIMenuElement] = []
// **Unser Tonmenü nur, wenn Apples eigenes es nicht kann.**
//
// Wird die Datei unverändert gespielt und stimmt die Spurzahl, liegen
// alle Spuren im Stream — dann zeigt Apple sie selbst, und zwei
// Knöpfe nebeneinander wären dasselbe zweimal. Beim Umrechnen liegt
// im Stream dagegen genau eine, und nur unser Menü führt weiter.
if aufnahme.audio.count > 1, aufnahme.audio.contains(where: \.rebuilds) {
menüs.append(UIMenu(
title: String(localized: "player.audioTracks"),
image: UIImage(systemName: "waveform"),
options: [.singleSelection],
children: einträge(aufnahme.audio, gesperrt: aufnahme.isRebuilding, aktion: onAudio)
))
}
// **Untertitel immer über uns**, auch wenn Apple es könnte: hier hängt
// der Stil mit drin, und der ist unserer — `AVTextStyleRule`s aus
// `SubtitleAppearance`, nicht die Systemeinstellung, die Apples Menü
// verstellt. Apples eigener Knopf ist deshalb abgeschaltet; siehe
// `TVPlayerCoordinator`.
menüs.append(UIMenu(
title: String(localized: "player.subtitleTracks"),
image: UIImage(systemName: "captions.bubble"),
children: [
// Inline: die Spuren stehen ohne Umweg da, der Stil darunter
// als eigene Untermenüs. Tiefer geht es nicht — die
// Transportleiste zeigt genau eine Ebene.
UIMenu(options: [.displayInline, .singleSelection],
children: einträge(aufnahme.subtitles,
gesperrt: aufnahme.isRebuilding,
aktion: onSubtitle)),
größenMenü(appearance),
darstellungsMenü(appearance),
]
))
return menüs
}
// MARK: - Die Spuren
private static func einträge(
_ liste: [TrackMenu.Entry],
gesperrt: Bool,
aktion: @escaping (Int?) -> Void
) -> [UIMenuElement] {
liste.map { eintrag in
UIAction(
title: eintrag.title,
// **Vorher ansagen, nicht hinterher erklären.** Steht hier
// nichts, ist die Sekundenpause danach eine Panne; steht es
// hier, ist sie eine angekündigte Folge. Das ist der billigste
// Teil dieser ganzen Sache und der wirksamste.
subtitle: eintrag.rebuilds ? String(localized: "player.rebuilds") : nil,
// **Keine zweite Bestellung, solange die erste läuft.** Sonst
// räumt `restart()` eine Engine ab, die eine andere gerade baut.
attributes: gesperrt ? [.disabled] : [],
state: eintrag.isSelected ? .on : .off
) { auslöser in
// **Sofort quittieren.** Das Menü schliesst sich beim Drücken,
// und darin ist kein Platz für einen Kreisel. Der Haken
// wandert, bevor sich am Bild etwas tut.
auslöser.state = .on
aktion(eintrag.id)
}
}
}
// MARK: - Der Stil
private static func größenMenü(_ appearance: SubtitleAppearance) -> UIMenu {
UIMenu(
title: String(localized: "subtitleStyle.sizeHeader"),
image: UIImage(systemName: "textformat.size"),
options: [.singleSelection],
children: SubtitleStyle.Size.allCases.map { größe in
UIAction(
title: größe.label,
state: appearance.size == größe ? .on : .off
) { auslöser in
auslöser.state = .on
appearance.size = größe
}
}
)
}
private static func darstellungsMenü(_ appearance: SubtitleAppearance) -> UIMenu {
UIMenu(
title: String(localized: "subtitleStyle.paletteHeader"),
image: UIImage(systemName: "paintpalette"),
options: [.singleSelection],
children: SubtitleStyle.Palette.allCases.map { palette in
UIAction(
title: palette.label,
state: appearance.palette == palette ? .on : .off
) { auslöser in
auslöser.state = .on
appearance.palette = palette
}
}
)
}
/// „Intro überspringen" unten rechts, solange eine Marke läuft.
///
/// Die tvOS-Entsprechung zu dem Knopf, den der iOS-Player selbst zeichnet.
static func contextualActions(
for aufnahme: TVPlayerSnapshot,
onSkip: @escaping () -> Void
) -> [UIAction] {
guard let beschriftung = aufnahme.markerLabel else { return [] }
return [UIAction(title: beschriftung) { _ in onSkip() }]
}
}
+104
View File
@@ -0,0 +1,104 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Die Suche am Fernseher.
///
/// **`.searchable` und keine eigene Tastatur.** tvOS macht daraus einen
/// eigenen Bildschirm mit Rastertastatur links und Ergebnissen rechts — die
/// Bedienung, die jeder von Apples eigenen Apps kennt. Etwas Eigenes zu bauen
/// hiesse, sie nachzuahmen und dabei die Diktierfunktion zu verlieren.
///
/// Das Modell dahinter ist dasselbe wie am Telefon: `SearchFeed` entprellt,
/// fragt und merkt sich die letzten Begriffe.
struct TVSearchView: View {
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
@State private var text = ""
@State private var feed = SearchFeed()
@State private var pfad = NavigationPath()
var body: some View {
NavigationStack(path: $pfad) {
inhalt
.navigationDestination(for: MediaRoute.self) { route in
TVDetailView(route: route)
}
}
.searchable(text: $text, prompt: Text("search.hint"))
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
// kurzen Pause. Siehe `SearchFeed`.
.task(id: text) { await feed.search(text, from: session.backend) }
}
@ViewBuilder
private var inhalt: some View {
if text.isEmpty {
zuletzt
} else if feed.hubs.isEmpty {
// Kein Unterschied zwischen „sucht noch" und „nichts gefunden":
// auf dem Fernseher tippt man langsam, und ein Text, der bei
// jedem Buchstaben zwischen beiden wechselt, flackert nur.
leer
} else {
ergebnisse
}
}
private var ergebnisse: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
ForEach(feed.hubs) { reihe in
TVMediaRow(
row: reihe,
onSelect: { quickPlay.start($0, backend: session.backend, coordinator: playback) }
)
}
}
.padding(.vertical, HaloSpacing.section)
}
.scrollClipDisabled()
.scrollIndicators(.never)
}
/// Die letzten Begriffe — als Knöpfe, nicht als Liste.
///
/// Am Fernseher ist das der eigentliche Wert der Suche: einmal getippt,
/// danach ein Druck. Eine Zeile mit Wischen zum Löschen gibt es hier
/// nicht, deshalb steht das Vergessen als eigener Knopf daneben.
@ViewBuilder
private var zuletzt: some View {
if feed.recent.isEmpty {
hinweis("search.hint")
} else {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text("search.recent")
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
ForEach(feed.recent, id: \.self) { eintrag in
Button(eintrag) { text = eintrag }
}
Button("search.forgetRecent", role: .destructive) { feed.forgetRecent() }
.padding(.top, HaloSpacing.regular)
}
.frame(maxWidth: 700, alignment: .leading)
.padding(HaloSpacing.section)
}
}
private var leer: some View {
hinweis("search.noResults")
}
private func hinweis(_ text: LocalizedStringKey) -> some View {
Text(text)
.font(.haloBody)
.foregroundStyle(Color.haloTextMuted)
.frame(maxWidth: 700)
.padding(HaloSpacing.section)
}
}
+158
View File
@@ -0,0 +1,158 @@
import SwiftUI
import HaloDesign
import JellyfinKit
/// Die Anmeldung am Fernseher.
///
/// **Dieselben vier Schritte wie am Telefon, andere Reihenfolge.** Das Modell
/// `JellyfinSignInModel` ist unverändert dasselbe — nur bekommt es hier
/// `preferring: .quickConnect`. Vor einem Fernseher ist der Code der bequemere
/// Weg: es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
/// das Gerät, auf dem man den Code bestätigt, liegt sowieso auf dem Sofa.
///
/// Der Weg über Benutzername und Passwort bleibt erreichbar — für Server, auf
/// denen Quick Connect abgeschaltet ist.
struct TVSignInView: View {
@Environment(HaloSession.self) private var session
@State private var model: JellyfinSignInModel?
var body: some View {
ZStack {
Color.haloCanvas.ignoresSafeArea()
if let model {
inhalt(for: model)
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
// liest sich aus drei Metern nicht mehr als Zeile.
.frame(maxWidth: 900)
} else {
ProgressView()
}
}
.task {
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
guard model == nil else { return }
let frisch = JellyfinSignInModel(
identity: session.jellyfinDeviceIdentity,
preferring: .quickConnect
)
frisch.onAuthenticated = { client, account, serverName in
Task {
await session.adoptJellyfin(
client: client, account: account, serverName: serverName
)
}
}
model = frisch
}
.onDisappear { model?.cancel() }
}
@ViewBuilder
private func inhalt(for model: JellyfinSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: HaloSpacing.section) {
Text(Branding.appName).font(.haloHero)
Text("signIn.tagline").font(.haloBody).foregroundStyle(Color.haloTextMuted)
switch model.step {
case .address:
adresse(model: model)
case .checking:
warten("signIn.jellyfin.searching")
case .quickConnect(let code):
codeAnzeigen(model: model, code: code)
case .credentials:
zugangsdaten(model: model)
case .signingIn:
warten("signIn.jellyfin.signingIn")
}
if let fehler = model.errorMessage {
Text(fehler).font(.haloBody).foregroundStyle(Color.haloDestructive)
}
}
.padding(HaloSpacing.section)
}
// MARK: - Die vier Schritte
private func adresse(model: JellyfinSignInModel) -> some View {
@Bindable var model = model
return VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text("signIn.jellyfin.addressPrompt").font(.haloRowHeader)
// Auf tvOS öffnet ein Textfeld beim Fokussieren die
// Bildschirmtastatur ganzseitig — deshalb steht hier nur das Feld
// und keine Maske drumherum.
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
.haloPlainEntry(isAddress: true)
Text("signIn.jellyfin.addressHint")
.font(.haloCaption).foregroundStyle(Color.haloTextMuted)
Button("signIn.jellyfin.connect") {
Task { await model.findServer() }
}
}
}
private func codeAnzeigen(model: JellyfinSignInModel, code: String) -> some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
if let name = model.serverName {
Text(name).font(.haloRowHeader)
}
// **Der Code ist hier der Inhalt, nicht eine Beschriftung.**
// Er wird aus drei Metern abgelesen und in ein Telefon getippt;
// in Fliesstextgrösse ist das eine Zumutung.
Text(code)
.font(.system(size: 96, weight: .bold, design: .monospaced))
.kerning(12)
.foregroundStyle(.white)
Text("signIn.jellyfin.quickConnectHint").font(.haloBody)
Label("signIn.jellyfin.waitingForApproval", systemImage: "hourglass")
.font(.haloCaption).foregroundStyle(Color.haloTextMuted)
HStack(spacing: HaloSpacing.regular) {
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
Button("signIn.jellyfin.otherAddress") { model.startOver() }
}
}
}
private func zugangsdaten(model: JellyfinSignInModel) -> some View {
@Bindable var model = model
return VStack(alignment: .leading, spacing: HaloSpacing.regular) {
if let name = model.serverName {
Text(name).font(.haloRowHeader)
}
TextField("signIn.jellyfin.userName", text: $model.userName)
.haloPlainEntry()
SecureField("signIn.jellyfin.password", text: $model.password)
Text("signIn.jellyfin.passwordNote")
.font(.haloCaption).foregroundStyle(Color.haloTextMuted)
HStack(spacing: HaloSpacing.regular) {
Button("signIn.jellyfin.submit") {
Task { await model.signInWithPassword() }
}
if model.offersQuickConnect {
Button("signIn.jellyfin.useCode") {
Task { await model.beginQuickConnect() }
}
}
Button("signIn.jellyfin.otherAddress") { model.startOver() }
}
}
}
private func warten(_ text: LocalizedStringKey) -> some View {
HStack(spacing: HaloSpacing.regular) {
ProgressView()
Text(text).font(.haloBody)
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import Foundation
/// Was ein Link aus dem Regal bedeutet.
///
/// **Ein reiner Typ, ohne Ansicht und ohne Netz.** Ein Tippfehler im Schema
/// lässt nichts abstürzen: ein Druck auf ein Plakat täte einfach nichts, ohne
/// Fehler und ohne Hinweis.
///
/// Geprüft ist er trotzdem nicht — das Fernseh-Ziel hat kein Testziel, und
/// ihn dafür nach MediaKit zu schieben hiesse, ein Schema dieser App in ein
/// Paket zu legen, das keine App kennt. Wer ihn anfasst, prüft ihn am Gerät:
/// `xcrun simctl openurl … halo://play/<kennung>`.
enum TVDeepLink: Equatable {
/// Abspielen, was dahintersteckt.
case play(id: String)
/// Die Detailseite zeigen.
case detail(id: String)
/// Muss mit `CFBundleURLSchemes` in der Info.plist übereinstimmen.
static let scheme = "halo"
init?(_ url: URL) {
guard url.scheme == Self.scheme else { return nil }
// **`host` und nicht `pathComponents[0]`.** Bei `halo://play/42`
// steht „play" im Rechnernamen, nicht im Pfad — der lautet schlicht
// „/42". Wer hier den Pfad zerlegt, bekommt die Kennung als Aktion
// und wundert sich.
let kennung = url.pathComponents.dropFirst().joined(separator: "/")
guard !kennung.isEmpty else { return nil }
switch url.host() {
case "play": self = .play(id: kennung)
case "item": self = .detail(id: kennung)
default: return nil
}
}
}
+101
View File
@@ -0,0 +1,101 @@
import SwiftUI
import HaloDesign
/// Was auf dem Fernseher zu sehen ist.
///
/// **Nur der Zustandswechsel, keine Navigation.** Das ist alles, was von
/// `RootView` auf tvOS übrig bleibt: dort sind es 556 Zeilen Seitenleiste,
/// Tab-Anpassung, gemessene Statusleistenhöhe und Mini-Player-Übergang, und
/// nichts davon gibt es hier. Deshalb eine eigene Datei und kein `#if` in der
/// bestehenden — ein `#if` wäre kein Zweig gewesen, sondern ein zweiter Rumpf
/// in derselben Datei.
struct TVRootView: View {
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
/// Wohin ein Link aus dem Regal führt, sobald der Titel geladen ist.
@State private var ziel: MediaRoute?
var body: some View {
ZStack {
Color.haloCanvas.ignoresSafeArea()
switch session.phase {
case .launching:
ProgressView()
case .signedOut:
TVSignInView()
case .connecting(let name):
verbinden(mit: name)
case .failed(let meldung):
fehlgeschlagen(meldung)
case .ready:
TVShell(deepLinkTarget: $ziel)
}
}
.task { await session.restore() }
// **Hier und nicht in der Startseite.** Von einer Kachel, aus dem
// Aufmacher und aus der Detailseite wird dasselbe gestartet; läge der
// Übergang weiter innen, gäbe es ihn dreimal.
//
// `fullScreenCover(item:)` gibt es auf tvOS seit 14, und
// `PlaybackRequest` ist bereits `Identifiable`.
.fullScreenCover(
item: Binding(
get: { playback.request },
set: { if $0 == nil { playback.close() } }
)
) { request in
TVPlayerScreen(request: request)
}
// Der Weg zurück aus dem Regal über der App.
.onOpenURL { adresse in
guard let link = TVDeepLink(adresse) else { return }
Task { await öffne(link) }
}
}
/// Holt den Titel und macht damit, was der Link sagt.
///
/// **Nachgeschlagen, nicht geraten.** Im Regal steht nur eine Kennung;
/// was dahintersteckt — Film, Serie, Folge — weiss erst der Server. Und
/// bei einer Serie ist „abspielen" die nächste ungesehene Folge, was
/// wiederum `QuickPlay` entscheidet.
private func öffne(_ link: TVDeepLink) async {
guard let backend = session.backend else { return }
let kennung = switch link {
case .play(let id), .detail(let id): id
}
guard let item = try? await backend.item(id: kennung) else { return }
switch link {
case .play:
quickPlay.start(item, backend: backend, coordinator: playback)
case .detail:
ziel = MediaRoute(ratingKey: item.id, title: item.title)
}
}
private func verbinden(mit name: String) -> some View {
VStack(spacing: HaloSpacing.regular) {
ProgressView()
Text(name).font(.haloRowHeader).foregroundStyle(.white)
}
}
private func fehlgeschlagen(_ meldung: String) -> some View {
VStack(spacing: HaloSpacing.regular) {
Text(meldung).font(.haloBody).foregroundStyle(.white)
HStack(spacing: HaloSpacing.regular) {
Button("action.retry") { Task { await session.retry() } }
Button("action.signOut") { session.signOut() }
}
}
.frame(maxWidth: 900)
}
}
+65
View File
@@ -0,0 +1,65 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Start, die Mediatheken und die Suche nebeneinander.
///
/// **Die Mediatheken als Reiter, nicht als Reihe.** Sie standen unten auf der
/// Startseite, und dorthin musste man erst scrollen — an allen Vorschlägen
/// vorbei, obwohl „ich will in die Filme" genau der Fall ist, in dem einem
/// die Vorschläge egal sind. Oben sind sie einen Wisch entfernt.
///
/// Damit fällt die Reihe unten weg: zwei Wege zum selben Raster sind auf
/// einem Fernseher kein Komfort, sondern eine Frage, welcher der richtige ist.
struct TVShell: View {
@Binding var deepLinkTarget: MediaRoute?
@Environment(HaloSession.self) private var session
/// Welcher Reiter offen ist.
///
/// Ausdrücklich gehalten, nicht dem System überlassen: ein Link aus dem
/// Regal über der App führt immer zur Startseite, und stünde gerade die
/// Suche offen, passierte sichtbar nichts.
@State private var reiter = Reiter.start
private enum Reiter: Hashable {
case start
case mediathek(String)
case suche
}
var body: some View {
TabView(selection: $reiter) {
Tab(value: Reiter.start) {
TVHomeView(deepLinkTarget: $deepLinkTarget)
} label: {
Text("sidebar.home")
}
ForEach(session.libraries, id: \.id) { mediathek in
Tab(value: Reiter.mediathek(mediathek.id)) {
// Jeder Reiter bekommt seinen eigenen Stapel. Ein
// gemeinsamer hiesse, dass ein Wechsel des Reiters die
// Detailseite des vorigen mitnimmt.
NavigationStack {
TVLibraryView(libraryID: mediathek.id)
}
} label: {
Text(mediathek.title)
}
}
// **`role: .search` und nicht ein gewöhnlicher Reiter.** tvOS
// setzt ihn dadurch dorthin, wo er in jeder anderen App auch
// steht, und gibt ihm die Lupe. Die Stelle bestimmt also Apple,
// nicht diese Datei.
Tab(value: Reiter.suche, role: .search) {
TVSearchView()
}
}
.onChange(of: deepLinkTarget) { _, neu in
if neu != nil { reiter = .start }
}
}
}