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.