**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>
103 lines
4.1 KiB
Swift
103 lines
4.1 KiB
Swift
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)
|
|
}
|
|
}
|