Ein Regal über der App — und ein Weg aus dem Player heraus

**Das Regal.** Wer im tvOS-Menü über der App steht, sah bisher das Icon
noch einmal gross. Jetzt stehen dort „Weiterschauen", „Als Nächstes" und
die Neuzugänge als Plakate, mit Fortschrittsbalken; ein Druck spielt ab,
ein Wisch nach unten öffnet die Detailseite.

**Warum es dafür eine App-Gruppe braucht.** Das Regal zeichnet eine
eigene Erweiterung, also ein anderer Prozess mit eigener Sandbox — und
tvOS ruft sie auf, während die App **nicht** läuft, mit weniger als einer
Sekunde Zeit. Kein Netzabruf, keine Anmeldung, kein Blättern. Also legt
die App beim Laden der Startseite hin, was das Regal braucht, und sonst
nichts: Titel, Bildadresse, Kennung, Fortschritt. Eine Datei, ein
`JSONDecoder`, fertig.

Die Bildadressen tragen den Zugangsschlüssel, weil Jellyfin ihn für
Bilder verlangt und die Erweiterung ihn sonst nirgends herbekäme. Die
Datei liegt in der App-Gruppe — erreichbar für die App und ihre
Erweiterungen, für sonst niemanden. Der Schlüsselbund bleibt, wo er ist.

**Und der Fehler, den du gefunden hast.** Solange der Stream noch
ausgehandelt wurde, gab es keinen Weg zurück: schwarzes Bild, Kreisel,
und der Menü-Knopf tat nichts. `AVPlayerViewController` fängt ihn nämlich
selbst ab — aber erst, wenn er einen Player mit einem Item hat, und genau
den gibt es in dem Moment noch nicht. Der Griff liegt jetzt eine Ebene
darüber, wo es ihn vom ersten Bild an gibt. Doppelt ist er nicht: greift
Apples Player zu, kommt der Befehl dort gar nicht mehr an.

**Was am Gerät noch offen ist.** Die App-Gruppe muss im Entwicklerkonto
eingetragen sein, sonst bleibt das Regal leer — im Simulator legt das
System sie von selbst an, auf einem echten Apple TV nicht. Geprüft ist,
dass die App die Datei schreibt (drei Reihen, richtige Adressen) und dass
tvOS das Schema kennt. Dass das Regal beim Darüberstehen auch zeichnet,
lässt sich ohne Fernbedienung nicht auslösen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-29 20:14:38 +02:00
co-authored by Claude Opus 5
parent e2714e058e
commit e801502c7a
16 changed files with 506 additions and 1 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.flixy</string>
</array>
</dict>
</plist>
+11
View File
@@ -18,6 +18,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.scarriffleservices.flixy.topshelf</string>
<key>CFBundleURLSchemes</key>
<array>
<string>flixy</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
+13
View File
@@ -8,6 +8,14 @@ import MediaKit
/// 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(FlixySession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@Environment(PlaybackCoordinator.self) private var playback
@@ -35,6 +43,11 @@ struct TVHomeView: View {
}
}
.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
+12
View File
@@ -61,6 +61,18 @@ struct TVPlayerScreen: View {
.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(
+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 … flixy://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 = "flixy"
init?(_ url: URL) {
guard url.scheme == Self.scheme else { return nil }
// **`host` und nicht `pathComponents[0]`.** Bei `flixy://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
}
}
}
+31 -1
View File
@@ -12,6 +12,10 @@ import FlixyDesign
struct TVRootView: View {
@Environment(FlixySession.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 {
@@ -35,7 +39,7 @@ struct TVRootView: View {
fehlgeschlagen(meldung)
case .ready:
TVHomeView()
TVHomeView(deepLinkTarget: $ziel)
}
}
.task { await session.restore() }
@@ -53,6 +57,32 @@ struct TVRootView: View {
) { 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 {