Beides mit derselben Rechnung, nur in einem anderen Fenster: vorn die ersten fünfzehn Minuten, hinten die letzten zehn. Was `Tonabgleich` findet, hängt allein daran, welchen Ausschnitt man ihm gibt — eine zweite Erkennung für den Abspann wäre dieselbe Suche ein zweites Mal. Je Staffel verglichen, nicht je Serie: Serien wechseln ihren Vorspann zwischen Staffeln, und über alle hinweg fände man nichts oder das Falsche. Gefundenes über fünf Minuten wird verworfen — zwei Folgen, die dieselbe Datei tragen, teilen vierzig, und darüber stünde sonst ein Überspringen-Knopf. Der Lauf geht auf Zuruf, nicht bei jedem Scan: er liest von jeder Folge ein Stück Ton und dauert Minuten. Der Knopf dafür steht auf der Serienseite, bei den Verwalterknöpfen. Die Marken gehen im Folgenvertrag mit — als „intro" und „credits", die Wörter, die die Apps von Jellyfin her ohnehin kennen. In der App zeichnet sie der vorhandene Überspringen-Knopf, der bisher nie etwas zu tun hatte; im Web ist er neu, rechtsbündig über der Zeitleiste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
7.5 KiB
Swift
203 lines
7.5 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,
|
|
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,
|
|
// **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,
|
|
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) })
|
|
}
|
|
}
|