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>
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -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,13 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "tv",
|
||||
"filename": "front.png",
|
||||
"scale": "1x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1012 B |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: " · ")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||