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
+7
View File
@@ -29,6 +29,13 @@ final class HomeFeed {
state = await LoadState.load(keepingPrevious: state) {
try await backend.homeRows()
}
#if os(tvOS)
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
if case .loaded(let reihen) = state {
TopShelfWriter.update(rows: reihen, backend: backend)
}
#endif
}
/// Die Reihen in der eingestellten Form.
+61
View File
@@ -0,0 +1,61 @@
import Foundation
import MediaKit
/// Legt für das Regal über der App hin, was es zeigen soll.
///
/// **Beim Laden der Startseite, nicht auf einem Zeitgeber.** Das Regal wird
/// gezeichnet, wenn jemand im Menü über der App steht — und dann ist die App
/// nicht gestartet. Was dort steht, ist also immer das, was beim letzten
/// Öffnen zu sehen war. Ein Zeitgeber änderte daran nichts und hielte nur die
/// App wach.
@MainActor
enum TopShelfWriter {
/// Die App-Gruppe, die App und Erweiterung teilen.
static let group = "group.com.scarriffleservices.flixy"
/// Die Kante, in der das Regal Plakate zeigt.
private static let posterWidth = 404
private static let posterHeight = 606
static func update(
rows: [MediaRowGroup],
backend: (any MediaBackend)?
) {
guard let backend else { return }
let abschnitte = rows
.prefix(TopShelfSnapshot.sectionLimit)
.map { reihe in
TopShelfSnapshot.Section(
id: reihe.id,
title: reihe.title,
entries: reihe.items
.prefix(TopShelfSnapshot.entryLimit)
.map { item in
TopShelfSnapshot.Entry(
id: item.id,
title: item.rowTitle,
subtitle: item.cardSubtitle,
// **Das Hochkant-Plakat, nicht `thumb`.** Bei
// einer Folge ist `thumb` das Szenenbild; im
// Regal stünde davon ein zufälliger
// Ausschnitt. Dieselbe Rückfallkette wie auf
// den Kacheln.
imageURL: backend.artworkURL(
path: item.posterArtworkPath,
width: posterWidth,
height: posterHeight
),
progress: item.watchProgress
)
}
)
}
.filter { !$0.entries.isEmpty }
TopShelfStore.write(
TopShelfSnapshot(sections: Array(abschnitte)),
group: group
)
}
}
+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 {
+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>
+29
View File
@@ -0,0 +1,29 @@
<?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>Flixy</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>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.tv-top-shelf</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).TopShelfProvider</string>
</dict>
</dict>
</plist>
+24
View File
@@ -0,0 +1,24 @@
import Foundation
/// Die beiden Zeichenketten, auf die sich App und Regal einigen müssen.
///
/// An **einer** Stelle, weil ein Tippfehler hier nichts abstürzen lässt: das
/// Regal bliebe einfach leer, oder ein Druck täte nichts, und beides sieht
/// aus wie „geht halt nicht" statt wie ein Fehler.
enum TopShelfIdentity {
/// Muss mit `TopShelfWriter.group` und beiden Entitlement-Dateien
/// übereinstimmen.
static let group = "group.com.scarriffleservices.flixy"
/// Muss mit `CFBundleURLSchemes` in der Info.plist des Fernseh-Ziels
/// übereinstimmen.
static let scheme = "flixy"
static func playURL(for id: String) -> URL {
URL(string: "\(scheme)://play/\(id)") ?? URL(string: "\(scheme)://")!
}
static func detailURL(for id: String) -> URL {
URL(string: "\(scheme)://item/\(id)") ?? URL(string: "\(scheme)://")!
}
}
+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
}
}
@@ -0,0 +1,102 @@
import Foundation
/// Was auf dem Regal über der App steht, wenn man sie im Menü anwählt.
///
/// **Warum ein eigener Abzug und nicht `LibraryCache`.** Das Regal wird von
/// einer **App-Erweiterung** gezeichnet, also von einem anderen Prozess mit
/// eigener Sandbox. Der kommt weder an den Schlüsselbund noch an den
/// Zwischenspeicher der App, und er soll auch gar nicht: er hat weniger als
/// eine Sekunde Zeit, bevor tvOS ihn abbricht. Ein Netzabruf, eine Anmeldung,
/// ein Blättern — alles ausgeschlossen.
///
/// Also legt die App hin, was das Regal braucht, und sonst nichts: Titel,
/// Bildadresse, Kennung. Fertig aufbereitet, eine Datei, ein `JSONDecoder`.
///
/// **Zur Bildadresse.** Sie trägt den Zugangsschlüssel des Servers, 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; hierher kommt nur, was ohnehin in jeder Bildanfrage steht.
public struct TopShelfSnapshot: Codable, Sendable, Equatable {
public struct Entry: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let subtitle: String?
/// Das Plakat, in der Grösse, die das Regal zeigt.
public let imageURL: URL?
/// Wie weit gesehen — für den Balken auf dem Plakat.
public let progress: Double?
public init(
id: String, title: String, subtitle: String? = nil,
imageURL: URL?, progress: Double? = nil
) {
self.id = id
self.title = title
self.subtitle = subtitle
self.imageURL = imageURL
self.progress = progress
}
}
public struct Section: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let entries: [Entry]
public init(id: String, title: String, entries: [Entry]) {
self.id = id
self.title = title
self.entries = entries
}
}
public let sections: [Section]
public let savedAt: Date
public init(sections: [Section], savedAt: Date = .now) {
self.sections = sections
self.savedAt = savedAt
}
/// Wie viele Reihen und wie viele Einträge darin höchstens stehen.
///
/// **Nicht aus Sparsamkeit.** Das Regal zeigt eine knappe Bildschirmbreite;
/// alles darüber hinaus lädt die Erweiterung nur, um es nie zu zeigen —
/// und sie hat dafür weniger als eine Sekunde.
public static let sectionLimit = 3
public static let entryLimit = 12
}
/// Wo der Abzug liegt und wie er dorthin kommt.
///
/// **In der App-Gruppe, nicht im Caches-Ordner.** Jeder Prozess hat seinen
/// eigenen; die App-Gruppe ist der einzige Ort, den App und Erweiterung
/// gemeinsam sehen.
public enum TopShelfStore {
/// Gibt `nil` zurück, wenn die App-Gruppe fehlt — und das ist der Fall,
/// bis sie im Entwicklerkonto eingetragen ist. Die Erweiterung zeigt dann
/// nichts, statt abzustürzen.
public static func location(group: String) -> URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: group)?
.appending(path: "topshelf.json", directoryHint: .notDirectory)
}
public static func write(_ snapshot: TopShelfSnapshot, group: String) {
guard let ziel = location(group: group),
let daten = try? JSONEncoder().encode(snapshot)
else { return }
// `.atomic`: die Erweiterung kann jederzeit lesen, auch mitten im
// Schreiben. Eine halb geschriebene Datei ergäbe eine leere Regalreihe
// statt eines Fehlers — und niemand käme darauf, warum.
try? daten.write(to: ziel, options: .atomic)
}
public static func read(group: String) -> TopShelfSnapshot? {
guard let quelle = location(group: group),
let daten = try? Data(contentsOf: quelle)
else { return nil }
return try? JSONDecoder().decode(TopShelfSnapshot.self, from: daten)
}
}
@@ -0,0 +1,50 @@
import Foundation
import Testing
@testable import MediaKit
@Suite("Der Abzug fürs Regal")
struct TopShelfSnapshotTests {
private func eintrag(_ id: String) -> TopShelfSnapshot.Entry {
.init(id: id, title: "Titel \(id)", imageURL: URL(string: "https://example.test/\(id).jpg"))
}
/// **Der Fall, der sonst erst auf dem Fernseher auffiele.** Die
/// Erweiterung entschlüsselt, was die App geschrieben hat; passt eines
/// von beiden nicht, bleibt das Regal leer — ohne Fehler, ohne Hinweis.
@Test("Was geschrieben wurde, kommt gleich wieder heraus")
func roundTrips() throws {
let original = TopShelfSnapshot(sections: [
.init(id: "continue", title: "Weiterschauen", entries: [eintrag("1"), eintrag("2")]),
])
let daten = try JSONEncoder().encode(original)
let zurück = try JSONDecoder().decode(TopShelfSnapshot.self, from: daten)
#expect(zurück == original)
}
/// Ohne App-Gruppe gibt es den geteilten Ort nicht — und bis sie im
/// Entwicklerkonto steht, ist das der Normalfall. Dann soll nichts
/// passieren, nicht abgestürzt werden.
///
/// **Geprüft wird die Harmlosigkeit, nicht das `nil`.** Dass der Ort
/// fehlt, lässt sich hier nicht behaupten: dieser Test läuft auf macOS,
/// und dort reicht das System für eine unbekannte Gruppe trotzdem einen
/// Ordner heraus — anders als auf tvOS. Die Zusage, auf die es ankommt,
/// gilt überall: beides geht durch, und gelesen wird nur, was auch
/// geschrieben wurde.
@Test("Eine unbekannte App-Gruppe bringt nichts zum Absturz")
func unknownGroupIsHarmless() {
let unbekannt = "group.flixy.test.\(UUID().uuidString)"
TopShelfStore.write(TopShelfSnapshot(sections: []), group: unbekannt)
let gelesen = TopShelfStore.read(group: unbekannt)
#expect(gelesen?.sections.isEmpty ?? true)
}
/// Die Grenzen sind kein Geschmack: das Regal zeigt eine knappe
/// Bildschirmbreite, und die Erweiterung hat weniger als eine Sekunde.
@Test("Die Grenzen sind eng genug, dass es in einer Sekunde reicht")
func limitsAreTight() {
#expect(TopShelfSnapshot.sectionLimit <= 3)
#expect(TopShelfSnapshot.entryLimit <= 20)
}
}
+38
View File
@@ -221,6 +221,7 @@ targets:
# ModelContainer und kein SwiftData.
- package: FlixyKit
product: FlixyDownloads
- target: FlixyTopShelf
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy.tv
@@ -232,12 +233,20 @@ targets:
# und der Katalog liegt unter FlixyTV/Resources.
ASSETCATALOG_COMPILER_APPICON_NAME: "App Icon & Top Shelf Image"
CODE_SIGN_STYLE: Automatic
CODE_SIGN_ENTITLEMENTS: FlixyTV/Flixy.entitlements
info:
path: FlixyTV/Info.plist
properties:
CFBundleDisplayName: Flixy
UIUserInterfaceStyle: Dark
ITSAppUsesNonExemptEncryption: false
# Über dieses Schema schickt das Regal zurück in die App. Ohne den
# Eintrag kennt tvOS es nicht, und ein Druck auf ein Plakat tut
# nichts — ohne Fehler, ohne Hinweis.
CFBundleURLTypes:
- CFBundleURLName: com.scarriffleservices.flixy.topshelf
CFBundleURLSchemes:
- flixy
# Was hier **nicht** steht, und warum:
#
# UILaunchScreen — tvOS hat kein Startbild dieser Art.
@@ -252,6 +261,35 @@ targets:
Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen
Jellyfin-Server zu erreichen.
# Das Regal über der App im tvOS-Menü.
#
# Eine eigene Erweiterung, weil tvOS sie zeichnet, während die App **nicht**
# läuft — und mit weniger als einer Sekunde Zeit. Sie liest deshalb nur eine
# Datei aus der App-Gruppe, die die App beim Laden der Startseite ablegt:
# kein Netz, kein Schlüsselbund, kein Blättern.
FlixyTopShelf:
type: tv-app-extension
platform: tvOS
sources:
- path: FlixyTopShelf
dependencies:
- package: FlixyKit
product: MediaKit
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy.tv.topshelf
PRODUCT_NAME: FlixyTopShelf
CODE_SIGN_STYLE: Automatic
CODE_SIGN_ENTITLEMENTS: FlixyTopShelf/FlixyTopShelf.entitlements
SKIP_INSTALL: YES
info:
path: FlixyTopShelf/Info.plist
properties:
CFBundleDisplayName: Flixy
NSExtension:
NSExtensionPointIdentifier: com.apple.tv-top-shelf
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).TopShelfProvider
# Nur zum Hinsehen: fährt die App mit der Attrappe in einen bestimmten
# Zustand und hält sie dort an, damit von außen fotografiert werden kann.
# Kein Teil der App, wird nie mitgeliefert.