Wer zur Zeitleiste greift, sieht jetzt das Standbild der Stelle — im Web beim Überfahren, in der App beim Ziehen. **Erzeugt wird beim Abspielen, nicht auf Vorrat.** Ein Lauf über 453 Filme und 4213 Folgen wäre ein Abend ffmpeg und mehrere Gigabyte für Titel, die niemand anfasst. Startet jemand einen Titel, stellt sich die Datei hinten an; bis zum ersten Griff zur Leiste ist der Lauf durch. Gemessen an einer erzeugten Datei: 191 ms für 250 Sekunden Film, ein Zweistünder liegt damit bei etwa fünf Sekunden. **Ein ffmpeg-Aufruf, nicht siebenhundert.** `fps=1/10,scale=320:-2, tile=10x10` macht aus einem Durchlauf fertige Blätter zu hundert Kacheln. Bezahlbar ist das durch `-skip_frame nokey` — dieselbe Abkürzung wie beim Abtasten der Abspannhelligkeit: nur Keyframes dekodieren. Dass die Vorschau dadurch um zwei Sekunden danebenliegen kann, sieht niemand. **Der Spieler lädt ein Blatt und verschiebt danach nur den Ausschnitt.** Beim Fahren über die Leiste wird kein einziger Abruf mehr fällig. Die Rechnung Sekunde → Kachel steht an drei Stellen — Server, Web, App — und deshalb auch dreimal dieselbe; weicht eine ab, hält man die Vorschau für ein unscharfes Bild statt für einen Fehler. Neun Prüfungen decken die Ränder ab: Sekunde null, letzte Kachel eines Blattes, erste des nächsten, und hinter dem Ende. **Die Kachelmasse werden gemessen, nicht gerechnet.** `scale=320:-2` hält das Seitenverhältnis, ein Breitbildfilm ergibt also flachere Kacheln. Geraten wäre jede Kachel um ein paar Bildpunkte verschoben. Aufgeräumt wird mit dem Transcode-Cache: der Namensanfang trägt Kennung und Änderungszeit der Mediendatei, verwaiste Blätter sind daran ohne Tabelle erkennbar. Der Ordner taucht unter „Speicher" auf. **Und die Pfeiltasten spulen wieder.** Hatte die Zeitleiste den Fokus, überliess der Spieler die Pfeile dem Browser — der verschiebt `value` und löst `input` aus, aber `change` kommt bei Tastatureingabe nicht verlässlich, also wurde nie gesprungen. Die Ausnahme fällt weg; Pfeil heisst überall ±10 Sekunden. Nativ wären es ohnehin 100 Millisekunden gewesen, weil `step` auf 0,1 steht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
8.2 KiB
Swift
277 lines
8.2 KiB
Swift
import Foundation
|
|
|
|
/// Was Flaro über die Leitung schickt.
|
|
///
|
|
/// Ein Spiegel der `Contracts.cs` auf der Serverseite, und nur das: hier wird
|
|
/// nichts gerechnet und nichts gedeutet. Die Übersetzung in die neutralen
|
|
/// Typen steht in `FlaroBackend+Mapping`.
|
|
///
|
|
/// **Die Felder heissen wie dort.** Keine `CodingKeys`, keine Umbenennung:
|
|
/// ASP.NET Core schreibt `camelCase`, Swift liest `camelCase`, und jede
|
|
/// Abweichung wäre eine Stelle, an der beim nächsten Serverfeld jemand
|
|
/// nachziehen müsste.
|
|
enum Wire {
|
|
struct Page<T: Decodable & Sendable>: Decodable, Sendable {
|
|
let items: [T]
|
|
let total: Int
|
|
}
|
|
|
|
struct SystemInfo: Decodable, Sendable {
|
|
let version: String?
|
|
let serverName: String?
|
|
let needsSetup: Bool
|
|
let attributions: [Attribution]?
|
|
}
|
|
|
|
struct Attribution: Decodable, Sendable {
|
|
let text: String
|
|
let logoUrl: String
|
|
let url: String
|
|
}
|
|
|
|
struct LoginResponse: Decodable, Sendable {
|
|
let token: String
|
|
let user: User
|
|
}
|
|
|
|
struct User: Decodable, Sendable {
|
|
let id: String
|
|
let userName: String
|
|
let isAdministrator: Bool
|
|
/// Das Profilbild, als Schlüssel in die Bildablage des Servers.
|
|
let avatarImage: String?
|
|
}
|
|
|
|
struct Library: Decodable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
let kind: String
|
|
let path: String
|
|
}
|
|
|
|
struct Stream: Decodable, Sendable {
|
|
let index: Int
|
|
let kind: String
|
|
let codec: String?
|
|
let language: String?
|
|
let title: String?
|
|
let isDefault: Bool
|
|
let isForced: Bool
|
|
let channels: Int?
|
|
let width: Int?
|
|
let height: Int?
|
|
}
|
|
|
|
struct File: Decodable, Sendable {
|
|
let id: String
|
|
let fileName: String
|
|
let sizeBytes: Int64
|
|
let container: String?
|
|
let durationSeconds: Double?
|
|
let streams: [Stream]
|
|
}
|
|
|
|
struct ItemState: Decodable, Sendable {
|
|
let positionSeconds: Double
|
|
let runtimeSeconds: Double?
|
|
let watched: Bool
|
|
let playCount: Int
|
|
let favourite: Bool
|
|
let rating: Int?
|
|
let dismissed: Bool
|
|
let lastPlayedAt: Date?
|
|
/// Ob der Server den Titel in „Weiterschauen" führt.
|
|
///
|
|
/// **Nicht dasselbe wie „Stelle über null".** Seit es eine
|
|
/// einstellbare Untergrenze gibt, sind das zwei Fragen: eine Sekunde
|
|
/// wird gemerkt, zählt aber nicht als angefangen.
|
|
let inProgress: Bool?
|
|
}
|
|
|
|
struct Movie: Decodable, Sendable {
|
|
let id: String
|
|
let title: String
|
|
let year: Int?
|
|
let overview: String?
|
|
let posterImage: String?
|
|
let backdropImage: String?
|
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
|
let heroImage: String?
|
|
let files: [File]
|
|
let state: ItemState?
|
|
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
|
let publishAt: Date?
|
|
/// Der Abspann, sofern er im Bild gefunden wurde.
|
|
let markers: [Marker]?
|
|
}
|
|
|
|
struct Series: Decodable, Sendable {
|
|
let id: String
|
|
let title: String
|
|
let year: Int?
|
|
let overview: String?
|
|
let posterImage: String?
|
|
let backdropImage: String?
|
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
|
let heroImage: String?
|
|
let episodeCount: Int
|
|
let seasons: [Int]
|
|
let unwatchedCount: Int
|
|
let publishAt: Date?
|
|
}
|
|
|
|
struct Episode: Decodable, Sendable {
|
|
let id: String
|
|
let season: Int
|
|
let episode: Int
|
|
let title: String?
|
|
let overview: String?
|
|
let stillImage: String?
|
|
let files: [File]
|
|
let state: ItemState?
|
|
let publishAt: Date?
|
|
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
|
|
/// nicht darstellbar: Titel und Bild der Serie fehlten.
|
|
let seriesId: String?
|
|
/// Vorspann und Abspann, sofern der Server sie gefunden hat.
|
|
///
|
|
/// Fehlt bei älteren Servern ganz — deshalb optional und nicht eine
|
|
/// leere Liste: ein `[]` im Vertrag zwänge jeden Server, das Feld zu
|
|
/// schicken.
|
|
let markers: [Marker]?
|
|
}
|
|
|
|
/// Ein überspringbarer Abschnitt.
|
|
struct Marker: Decodable, Sendable {
|
|
/// „intro" oder „credits" — dieselben Wörter wie bei Jellyfin.
|
|
let kind: String
|
|
let startSeconds: Double
|
|
let endSeconds: Double
|
|
}
|
|
|
|
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
|
struct Started: Decodable, Sendable {
|
|
let id: String
|
|
let kind: String
|
|
let title: String
|
|
let subTitle: String?
|
|
let season: Int?
|
|
let episode: Int?
|
|
let year: Int?
|
|
let posterImage: String?
|
|
let backdropImage: String?
|
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
|
let heroImage: String?
|
|
let state: ItemState
|
|
/// Zu welcher Serie die Folge gehört.
|
|
let seriesId: String?
|
|
/// Das breite Bild der Serie — für den Aufmacher, nicht für die
|
|
/// Kachel: die will das Standbild der Folge.
|
|
let seriesBackdropImage: String?
|
|
let publishAt: Date?
|
|
}
|
|
|
|
struct CodeResponse: Decodable, Sendable {
|
|
let code: String
|
|
let handle: String
|
|
let expiresAt: Date
|
|
}
|
|
|
|
/// `state` ist „pending", „approved" oder „expired".
|
|
struct CodeState: Decodable, Sendable {
|
|
let state: String
|
|
let login: LoginResponse?
|
|
}
|
|
|
|
struct CodeRequest: Encodable, Sendable {
|
|
let deviceName: String
|
|
let deviceId: String
|
|
}
|
|
|
|
struct Delivery: Decodable, Sendable {
|
|
let method: String
|
|
let reasons: [String]
|
|
let audioToEncode: [Int]
|
|
let subtitlesDropped: [Int]
|
|
let packageState: String
|
|
let progress: Double
|
|
let error: String?
|
|
}
|
|
|
|
// --- Was hinausgeht --------------------------------------------------
|
|
|
|
/// Was das Gerät kann und was gewünscht ist.
|
|
///
|
|
/// **Ohne Codec-Angaben nimmt der Server Apples Profil an**, und das ist
|
|
/// hier genau richtig: dieses Gerät *ist* Apple. Eine eigene Liste wäre
|
|
/// eine zweite Wahrheit neben der, die der Server ohnehin pflegt.
|
|
struct StreamRequest: Encodable, Sendable {
|
|
var height: Int?
|
|
var audioStream: Int?
|
|
var startSeconds: Double
|
|
var transcode: Bool
|
|
}
|
|
|
|
struct Stromspur: Decodable, Sendable {
|
|
let index: Int
|
|
let label: String
|
|
let language: String?
|
|
let isDefault: Bool
|
|
}
|
|
|
|
struct Stromauskunft: Decodable, Sendable {
|
|
let sessionId: String
|
|
let url: String
|
|
/// Dieselbe Wiedergabe mit Untertitelspuren — fehlt bei Direktwiedergabe.
|
|
let masterUrl: String?
|
|
/// „direct", „remux" oder „transcode".
|
|
let method: String
|
|
/// Ab welcher Sekunde des Films dieser Strom beginnt.
|
|
let startSeconds: Double
|
|
let height: Int?
|
|
let audioStream: Int?
|
|
let audioStreams: [Stromspur]
|
|
let subtitleStreams: [Stromspur]
|
|
}
|
|
|
|
struct Sprachangabe: Decodable, Sendable {
|
|
let code: String
|
|
let name: String
|
|
}
|
|
|
|
struct Sprachauswahl: Decodable, Sendable {
|
|
let audio: [Sprachangabe]
|
|
let subtitles: [Sprachangabe]
|
|
let partial: Int
|
|
}
|
|
|
|
struct LoginRequest: Encodable, Sendable {
|
|
let userName: String
|
|
let password: String
|
|
let deviceName: String
|
|
let deviceId: String
|
|
}
|
|
|
|
struct ProgressRequest: Encodable, Sendable {
|
|
let positionSeconds: Double
|
|
let runtimeSeconds: Double?
|
|
}
|
|
|
|
struct WatchedRequest: Encodable, Sendable { let watched: Bool }
|
|
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
|
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
|
}
|
|
|
|
extension Wire {
|
|
/// Was der Server über die Vorschaubilder sagt.
|
|
struct Vorschau: Decodable, Sendable {
|
|
let fertig: Bool
|
|
let intervall: Double
|
|
let spalten: Int
|
|
let zeilen: Int
|
|
let kachelBreite: Int
|
|
let kachelHoehe: Int
|
|
let blaetter: Int
|
|
}
|
|
}
|