diff --git a/Flixy/Session/HomeFeed.swift b/Flixy/Session/HomeFeed.swift
index 68788f8..4dedb6e 100644
--- a/Flixy/Session/HomeFeed.swift
+++ b/Flixy/Session/HomeFeed.swift
@@ -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.
diff --git a/Flixy/Session/TopShelfWriter.swift b/Flixy/Session/TopShelfWriter.swift
new file mode 100644
index 0000000..f9077a8
--- /dev/null
+++ b/Flixy/Session/TopShelfWriter.swift
@@ -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
+ )
+ }
+}
diff --git a/FlixyTV/Flixy.entitlements b/FlixyTV/Flixy.entitlements
new file mode 100644
index 0000000..aa7bef7
--- /dev/null
+++ b/FlixyTV/Flixy.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.scarriffleservices.flixy
+
+
+
diff --git a/FlixyTV/Info.plist b/FlixyTV/Info.plist
index 5e5ecc3..7d72703 100644
--- a/FlixyTV/Info.plist
+++ b/FlixyTV/Info.plist
@@ -18,6 +18,17 @@
APPL
CFBundleShortVersionString
1.0
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ com.scarriffleservices.flixy.topshelf
+ CFBundleURLSchemes
+
+ flixy
+
+
+
CFBundleVersion
1
ITSAppUsesNonExemptEncryption
diff --git a/FlixyTV/Views/Home/TVHomeView.swift b/FlixyTV/Views/Home/TVHomeView.swift
index bb30ddd..257ba97 100644
--- a/FlixyTV/Views/Home/TVHomeView.swift
+++ b/FlixyTV/Views/Home/TVHomeView.swift
@@ -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
diff --git a/FlixyTV/Views/Player/TVPlayerScreen.swift b/FlixyTV/Views/Player/TVPlayerScreen.swift
index 1853c70..7ee9a80 100644
--- a/FlixyTV/Views/Player/TVPlayerScreen.swift
+++ b/FlixyTV/Views/Player/TVPlayerScreen.swift
@@ -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(
diff --git a/FlixyTV/Views/TVDeepLink.swift b/FlixyTV/Views/TVDeepLink.swift
new file mode 100644
index 0000000..90f5536
--- /dev/null
+++ b/FlixyTV/Views/TVDeepLink.swift
@@ -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/`.
+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
+ }
+ }
+}
diff --git a/FlixyTV/Views/TVRootView.swift b/FlixyTV/Views/TVRootView.swift
index 700abb9..275b055 100644
--- a/FlixyTV/Views/TVRootView.swift
+++ b/FlixyTV/Views/TVRootView.swift
@@ -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 {
diff --git a/FlixyTopShelf/FlixyTopShelf.entitlements b/FlixyTopShelf/FlixyTopShelf.entitlements
new file mode 100644
index 0000000..aa7bef7
--- /dev/null
+++ b/FlixyTopShelf/FlixyTopShelf.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.scarriffleservices.flixy
+
+
+
diff --git a/FlixyTopShelf/Info.plist b/FlixyTopShelf/Info.plist
new file mode 100644
index 0000000..9959438
--- /dev/null
+++ b/FlixyTopShelf/Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Flixy
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.tv-top-shelf
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).TopShelfProvider
+
+
+
diff --git a/FlixyTopShelf/TopShelfIdentity.swift b/FlixyTopShelf/TopShelfIdentity.swift
new file mode 100644
index 0000000..3360187
--- /dev/null
+++ b/FlixyTopShelf/TopShelfIdentity.swift
@@ -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)://")!
+ }
+}
diff --git a/FlixyTopShelf/TopShelfProvider.swift b/FlixyTopShelf/TopShelfProvider.swift
new file mode 100644
index 0000000..29e6722
--- /dev/null
+++ b/FlixyTopShelf/TopShelfProvider.swift
@@ -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
+ }
+}
diff --git a/Packages/FlixyKit/Sources/MediaKit/TopShelfSnapshot.swift b/Packages/FlixyKit/Sources/MediaKit/TopShelfSnapshot.swift
new file mode 100644
index 0000000..9bd4c7d
--- /dev/null
+++ b/Packages/FlixyKit/Sources/MediaKit/TopShelfSnapshot.swift
@@ -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)
+ }
+}
diff --git a/Packages/FlixyKit/Tests/MediaKitTests/.keep b/Packages/FlixyKit/Tests/MediaKitTests/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/Packages/FlixyKit/Tests/MediaKitTests/TopShelfSnapshotTests.swift b/Packages/FlixyKit/Tests/MediaKitTests/TopShelfSnapshotTests.swift
new file mode 100644
index 0000000..09ab8de
--- /dev/null
+++ b/Packages/FlixyKit/Tests/MediaKitTests/TopShelfSnapshotTests.swift
@@ -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)
+ }
+}
diff --git a/project.yml b/project.yml
index 0cc7f17..8c02dda 100644
--- a/project.yml
+++ b/project.yml
@@ -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.