**Der Aufmacher darf wechseln.** Bisher hing hinter jedem Titel genau ein Hintergrundbild, und das Karussell war nach einer Woche ein Standbild. Jetzt lassen sich bis zu fünf Aufnahmen aussuchen — im Bilderdialog unter einem vierten Reiter, gespeist aus denselben Hintergründen. Welches gerade gilt, entscheidet der Server aus Uhrzeit und Kennung: alle fünf Stunden ein anderes, überall dasselbe — im Web, in der App, nach einem Neustart. Ein Zufall je Abruf hiesse, dass das Bild beim Blättern springt; eine gespeicherte Wahl hiesse einen Schreibvorgang je Titel und Fenster. Die Kennung geht mit in die Rechnung, damit nicht alle Titel im selben Augenblick umschalten. Plakat und Hintergrund bleiben unberührt. Sie stehen hinter Kacheln und Kopfzeilen, und dort soll nichts wechseln. **Und der Abspann zählt als Schluss.** Eine Serie hängt sechs Minuten Abspann an eine Folge von fünfundvierzig; neunzig Prozent liegen dann mittendrin, und die Folge gilt erst als gesehen, wenn man ihn sich ganz ansieht. Die Schwelle herunterzudrehen hiesse, jeden anderen Titel zu früh abzuhaken. Der erkannte Abspann ist die genauere Antwort auf dieselbe Frage — er zieht die Schwelle nach vorn, nie nach hinten. Dazu der Verlauf über dem Aufmacher: er war immer noch zu dunkel. Jetzt trägt der Schattenwurf der Schrift, was vorher eine Decke über dem ganzen Bild tat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.6 KiB
Swift
206 lines
7.6 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// Aus Flaros Antworten werden neutrale Typen.
|
|
///
|
|
/// Die einzige Stelle, an der Flaros Schreibweise in die der App übersetzt
|
|
/// wird. Alles darüber kennt nur noch `MediaItem`.
|
|
extension Wire.Movie {
|
|
func neutral() -> MediaItem {
|
|
MediaItem(
|
|
id: id,
|
|
kind: .movie,
|
|
title: title,
|
|
summary: overview,
|
|
year: year,
|
|
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
|
thumb: posterImage,
|
|
art: backdropImage,
|
|
heroArt: heroImage,
|
|
playCount: state?.playCount,
|
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
|
lastPlayedAt: state?.lastPlayedAt,
|
|
isFavourite: state?.favourite ?? false,
|
|
isInProgress: state?.inProgress,
|
|
availableFrom: publishAt,
|
|
versions: [Wire.fassung(id: id, files: files)]
|
|
)
|
|
}
|
|
}
|
|
|
|
extension Wire.Series {
|
|
func neutral() -> MediaItem {
|
|
MediaItem(
|
|
id: id,
|
|
kind: .show,
|
|
title: title,
|
|
summary: overview,
|
|
year: year,
|
|
thumb: posterImage,
|
|
art: backdropImage,
|
|
heroArt: heroImage,
|
|
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
|
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
|
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
|
// zu derselben Aussage zu haben.
|
|
availableFrom: publishAt,
|
|
episodeCount: episodeCount,
|
|
playedEpisodeCount: episodeCount - unwatchedCount,
|
|
childCount: seasons.count
|
|
)
|
|
}
|
|
}
|
|
|
|
extension Wire.Episode {
|
|
func neutral(series: Wire.Series? = nil) -> MediaItem {
|
|
MediaItem(
|
|
id: id,
|
|
kind: .episode,
|
|
title: title ?? "Folge \(episode)",
|
|
summary: overview,
|
|
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
|
thumb: stillImage,
|
|
seriesThumb: series?.posterImage,
|
|
seriesArt: series?.backdropImage,
|
|
index: episode,
|
|
parentIndex: season,
|
|
seriesTitle: series?.title,
|
|
// Die Serie aus dem Vertrag, wenn sie nicht mitgegeben wurde:
|
|
// ohne sie führte der Weg zurück zur Serie ins Leere.
|
|
seriesID: series?.id ?? seriesId,
|
|
playCount: state?.playCount,
|
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
|
lastPlayedAt: state?.lastPlayedAt,
|
|
isFavourite: state?.favourite ?? false,
|
|
isInProgress: state?.inProgress,
|
|
availableFrom: publishAt,
|
|
versions: [Wire.fassung(id: id, files: files)],
|
|
// **Vorspann und Abspann, wenn der Server sie kennt.**
|
|
// Ältere Server schicken das Feld gar nicht; dann ist die
|
|
// Liste leer und die Spieler zeigen schlicht keinen Knopf.
|
|
markers: (markers ?? []).map {
|
|
MediaMarker(
|
|
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
|
|
startMilliseconds: Int($0.startSeconds * 1000),
|
|
endMilliseconds: Int($0.endSeconds * 1000))
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
extension Wire.Started {
|
|
/// Die Kachel aus „Weiterschauen" als Titel.
|
|
///
|
|
/// **Ohne Fassungen.** Die Reihe nennt keine Dateien, und das ist
|
|
/// richtig so: eine Kachel zeigt Plakat, Titel und Balken. Wer sie
|
|
/// antippt, holt den Titel einzeln — und bekommt dann alles.
|
|
func neutral() -> MediaItem {
|
|
let folge = kind == "episode"
|
|
return MediaItem(
|
|
id: id,
|
|
kind: folge ? .episode : .movie,
|
|
title: folge ? (subTitle ?? "Folge \(episode ?? 0)") : title,
|
|
year: year,
|
|
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
|
thumb: folge ? backdropImage : posterImage,
|
|
// **Für die Fläche über den halben Bildschirm das Bild der
|
|
// Serie.** `backdropImage` ist bei einer Folge ihr Standbild —
|
|
// richtig für die Kachel, falsch für den Aufmacher: wer der
|
|
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
|
|
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
|
|
seriesThumb: folge ? posterImage : nil,
|
|
heroArt: heroImage,
|
|
index: episode,
|
|
parentIndex: season,
|
|
seriesTitle: folge ? title : nil,
|
|
seriesID: folge ? seriesId : nil,
|
|
playCount: state.playCount,
|
|
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
|
lastPlayedAt: state.lastPlayedAt,
|
|
isFavourite: state.favourite,
|
|
isInProgress: state.inProgress,
|
|
availableFrom: publishAt
|
|
)
|
|
}
|
|
}
|
|
|
|
extension Wire.Library {
|
|
func neutral() -> MediaLibrary {
|
|
MediaLibrary(
|
|
id: id,
|
|
title: name,
|
|
// Flaro kennt genau zwei Arten. Was es nicht gibt, als `.other`
|
|
// durchzulassen wäre eine Vorsorge für einen Fall, den der
|
|
// Server gar nicht erzeugen kann.
|
|
kind: kind.lowercased() == "shows" ? .show : .movie)
|
|
}
|
|
}
|
|
|
|
extension Wire.File {
|
|
func neutral(itemID: String) -> MediaFile {
|
|
MediaFile(
|
|
id: id,
|
|
key: "/api/v1/files/\(id)/download",
|
|
itemID: itemID,
|
|
container: container,
|
|
size: sizeBytes,
|
|
duration: durationSeconds.map { Int($0 * 1000) },
|
|
file: fileName,
|
|
streams: streams.map { $0.neutral() })
|
|
}
|
|
}
|
|
|
|
extension Wire.Stream {
|
|
func neutral() -> MediaStream {
|
|
let art: MediaStream.Kind = switch kind.lowercased() {
|
|
case "video": .video
|
|
case "subtitle": .subtitle
|
|
default: .audio
|
|
}
|
|
|
|
return MediaStream(
|
|
// **Die Spurnummer ist die Kennung.** `MediaStream.index` liest
|
|
// sie aus `id` zurück, und daran hängt die Spurwahl beim
|
|
// Umpacken. Eine eigene Kennung wäre hier eine, die niemand
|
|
// zurückrechnen kann.
|
|
id: String(index),
|
|
kind: art,
|
|
codec: codec,
|
|
language: language,
|
|
languageCode: language,
|
|
displayTitle: title ?? language,
|
|
channels: channels,
|
|
isSelected: isDefault,
|
|
isForced: isForced,
|
|
title: title,
|
|
isDefault: isDefault)
|
|
}
|
|
}
|
|
|
|
extension Wire {
|
|
/// Eine Fassung aus den Dateien eines Titels.
|
|
///
|
|
/// **Alle Dateien in einer Fassung**, nicht eine Fassung je Datei: Flaro
|
|
/// führt mehrere Dateien zu einem Titel als Fassungen desselben Films —
|
|
/// 4K und 1080p nebeneinander — und `MediaVersion.files` ist genau dafür
|
|
/// da. Die Eckdaten kommen aus der ersten Datei; welche gespielt wird,
|
|
/// entscheidet die App.
|
|
static func fassung(id: String, files: [Wire.File]) -> MediaVersion {
|
|
let erste = files.first
|
|
let bild = erste?.streams.first { $0.kind.lowercased() == "video" }
|
|
let ton = erste?.streams.first { $0.kind.lowercased() == "audio" }
|
|
|
|
return MediaVersion(
|
|
id: erste?.id ?? id,
|
|
container: erste?.container,
|
|
videoCodec: bild?.codec,
|
|
audioCodec: ton?.codec,
|
|
videoResolution: bild?.height.map(String.init),
|
|
width: bild?.width,
|
|
height: bild?.height,
|
|
audioChannels: ton?.channels,
|
|
duration: erste?.durationSeconds.map { Int($0 * 1000) },
|
|
files: files.map { $0.neutral(itemID: id) })
|
|
}
|
|
}
|