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
+70
View File
@@ -0,0 +1,70 @@
import Foundation
import MediaKit
import TVServices
/// Das Regal über der App im tvOS-Menü.
///
/// **Was hier nicht passiert.** Kein Netzabruf, keine Anmeldung, kein
/// Blättern. tvOS ruft diese Erweiterung auf, während jemand im Menü über der
/// App steht, und bricht sie ab, wenn sie zu lange braucht. Alles, was sie
/// zeigt, hat die App beim letzten Öffnen abgelegt — eine Datei, ein
/// `JSONDecoder`, fertig.
///
/// **Und was sie nicht weiss.** Sie kennt weder Server noch Token. Die
/// Bildadressen im Abzug tragen den Zugangsschlüssel bereits, weil Jellyfin
/// ihn für Bilder verlangt; damit muss diese Erweiterung nie etwas erfragen.
final class TopShelfProvider: TVTopShelfContentProvider {
/// **Mit Rückruf statt `async`.** Die asynchrone Fassung verlangt einen
/// `Sendable`-Rückgabewert, und `TVTopShelfContent` ist keiner — die
/// Objekte gehören UIKit. Der Rückruf hat diese Anforderung nicht, und
/// hier gibt es ohnehin nichts zu warten: eine Datei lesen und
/// entschlüsseln.
override func loadTopShelfContent(
completionHandler: @escaping ((any TVTopShelfContent)?) -> Void
) {
completionHandler(inhalt())
}
private func inhalt() -> (any TVTopShelfContent)? {
guard let abzug = TopShelfStore.read(group: TopShelfIdentity.group),
!abzug.sections.isEmpty
else {
// Nichts abgelegt — die App war noch nie offen oder der Server
// hat nie geantwortet. Dann bleibt es beim Icon; das ist
// ehrlicher als eine leere Reihe mit Überschrift.
return nil
}
let abschnitte = abzug.sections.map { abschnitt in
let inhalte = abschnitt.entries.map(item(from:))
let reihe = TVTopShelfItemCollection(items: inhalte)
reihe.title = abschnitt.title
return reihe
}
return TVTopShelfSectionedContent(sections: abschnitte)
}
private func item(from eintrag: TopShelfSnapshot.Entry) -> TVTopShelfSectionedItem {
let element = TVTopShelfSectionedItem(identifier: eintrag.id)
element.title = eintrag.title
// **Hochkant.** Dieselbe Form wie die Plakate in der App; ein
// 16:9-Bild in einem Hochkantrahmen zeigt einen zufälligen
// Ausschnitt und ist zum Wiedererkennen wertlos.
element.imageShape = .poster
if let adresse = eintrag.imageURL {
element.setImageURL(adresse, for: .screenScale1x)
element.setImageURL(adresse, for: .screenScale2x)
}
if let fortschritt = eintrag.progress {
element.playbackProgress = fortschritt
}
// **Abspielen, nicht nur öffnen.** Wer im Menü über einem angefangenen
// Film steht und drückt, will weiterschauen — nicht erst eine
// Detailseite sehen. Die App entscheidet dann, was „abspielen" hier
// heisst: bei einer Serie die nächste ungesehene Folge.
element.playAction = TVTopShelfAction(url: TopShelfIdentity.playURL(for: eintrag.id))
element.displayAction = TVTopShelfAction(url: TopShelfIdentity.detailURL(for: eintrag.id))
return element
}
}