Die App meldete auf der Startseite „Dieser Titel ist auf dem Server nicht
mehr vorhanden" — über eine Folge, die sie selbst gerade anzeigte.
Gemessen im Quelltext: `item(id:)` probiert `/movies/{id}`, dann
`/series/{id}`, und wirft sonst `.notFound`. In „Weiterschauen" steht
eine Folge mit ihrer eigenen Kennung, und einen Endpunkt für eine
einzelne Folge gab es gar nicht. Jetzt gibt es `GET /api/v1/episodes/{id}`
— mit `SeriesId`, denn ohne die Serie fehlen der Folge Titel und Bild.
Im Simulator nachgestellt und nachgeprüft: die Seite steht.
**Zuletzt hinzugefügt.** Die App zeigte „Filme" und „Serien" — den Anfang
der Mediathek in der Reihenfolge der Datenbank, also immer denselben.
Jetzt je Mediathek eine Reihe „Neu in …", sortiert nach Zugang. Die
Kennungen beginnen nun mit `latest`, und daran hängen zwei Einstellungen,
die an einem Flaro-Server deshalb nichts taten: „Gesehenes in Neuzugängen
ausblenden" und „Aufmacher nur aus Neuzugängen".
**Geplante Titel.** `MediaItem` kennt jetzt `availableFrom`. Aufmacher und
Detailseite zeigen statt des Knopfes „Bald ist es soweit" mit dem Termin.
Die Kachel spielte trotzdem los — im Simulator kam der Spieler bis „Wird
vorbereitet …" —, also zählt ein geplanter Titel jetzt wie eine Sammlung:
nichts zu starten, der Tipp führt auf die Seite.
**Und das Bild im Aufmacher.** Bei einer angefangenen Folge stand dort ihr
Standbild statt des Bildes der Serie; wer der Serie eines ausgesucht
hatte, fand ein anderes vor. Die Kachel behält das Standbild — sie zeigt,
wo man steht —, der Aufmacher bekommt `SeriesBackdropImage`.
**Die Punkte im Karussell waren 26 × 3 Pixel.** Von drei Klicks auf
denselben Punkt traf nur der auf der Mittellinie; fünf Pixel daneben
geschah nichts. Der Knopf ist jetzt 27 Pixel hoch, sichtbar bleiben 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
6.9 KiB
Swift
191 lines
6.9 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,
|
|
playCount: state?.playCount,
|
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
|
lastPlayedAt: state?.lastPlayedAt,
|
|
isFavourite: state?.favourite ?? false,
|
|
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,
|
|
// **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,
|
|
availableFrom: publishAt,
|
|
versions: [Wire.fassung(id: id, files: files)]
|
|
)
|
|
}
|
|
}
|
|
|
|
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,
|
|
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,
|
|
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) })
|
|
}
|
|
}
|