**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>
365 lines
13 KiB
Swift
365 lines
13 KiB
Swift
import Foundation
|
||
|
||
/// Ein Eintrag in der Mediathek — Film, Serie, Staffel oder Folge.
|
||
///
|
||
/// Die Form, in der die Oberfläche Titel sieht, egal von welchem Server sie
|
||
/// stammen. Plex und Jellyfin liefern dieselben Dinge unter anderen Namen;
|
||
/// jeder Server-Adapter übersetzt einmal hierher, und danach ist die Herkunft
|
||
/// ohne Belang.
|
||
///
|
||
/// Die Namen sind dort geblieben, wo sie ohnehin neutral waren — `title`,
|
||
/// `summary`, `year`, `index`. Umbenannt wurde nur, was Plex-Jargon ist:
|
||
/// `ratingKey` heißt jetzt `id`, `viewOffset` heißt `resumeMilliseconds`, und
|
||
/// aus dem „Großelternteil" ist die Serie geworden, die damit gemeint war.
|
||
public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||
public enum Kind: String, Sendable, Codable, CaseIterable {
|
||
case movie, show, season, episode, clip, collection
|
||
}
|
||
|
||
/// Die Kennung, unter der der Server den Eintrag führt.
|
||
///
|
||
/// Bei Plex eine Zahl als Zeichenkette, bei Jellyfin eine GUID. Für die
|
||
/// App ist beides nur ein undurchsichtiger Schlüssel — **außer** beim
|
||
/// Herunterladen, wo er mit einem Vorsatz versehen wird, damit sich zwei
|
||
/// Server nicht ins Gehege kommen.
|
||
public let id: String
|
||
/// Der serverinterne Pfad, falls einer gebraucht wird.
|
||
public let path: String?
|
||
public let kind: Kind?
|
||
public let title: String
|
||
public let sortTitle: String?
|
||
public let summary: String?
|
||
public let year: Int?
|
||
/// Laufzeit in Millisekunden.
|
||
public let duration: Int?
|
||
public let contentRating: String?
|
||
/// Bewertung der Kritik, 0–10.
|
||
public let rating: Double?
|
||
/// Bewertung des Publikums, 0–10.
|
||
public let audienceRating: Double?
|
||
|
||
// Bildpfade. Relativ zum Server — die vollständige Adresse entsteht erst
|
||
// beim Abruf, weil sie Token und Größenangaben braucht.
|
||
public let thumb: String?
|
||
public let art: String?
|
||
/// Das Bild der Staffel.
|
||
public let parentThumb: String?
|
||
/// Das Bild der Serie. Bei Plex hieß das „grandparent" — gemeint war immer
|
||
/// die Serie, und so heißt es jetzt auch.
|
||
public let seriesThumb: String?
|
||
public let seriesArt: String?
|
||
/// Das Bild, das jetzt im Aufmacher gilt.
|
||
///
|
||
/// **Vom Server gewählt, nicht hier.** Wer zu einem Titel mehrere
|
||
/// Aufnahmen ausgesucht hat, bekommt alle paar Stunden eine andere — und
|
||
/// zwar überall dieselbe. `nil` heisst: keine ausgesucht, dann gilt das
|
||
/// Hintergrundbild.
|
||
public let heroArt: String?
|
||
|
||
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||
// Staffel.
|
||
public let index: Int?
|
||
public let parentIndex: Int?
|
||
public let parentTitle: String?
|
||
public let parentID: String?
|
||
public let seriesTitle: String?
|
||
public let seriesID: String?
|
||
|
||
// Fortschritt.
|
||
public let playCount: Int?
|
||
/// Wie weit gesehen wurde, in Millisekunden.
|
||
public let resumeMilliseconds: Int?
|
||
public let lastPlayedAt: Date?
|
||
public let addedAt: Date?
|
||
/// Ob der Titel als Favorit markiert ist.
|
||
///
|
||
/// Plex kennt das nicht und meldet immer `false`; Jellyfin führt eine
|
||
/// eigene Liste. Ein Feld, das ein Server nicht füllt, ist ehrlicher als
|
||
/// eine Fähigkeit, die es nur auf einem gibt.
|
||
public let isFavourite: Bool
|
||
|
||
/// Ob der Server den Titel als angefangen führt.
|
||
///
|
||
/// **Die Entscheidung gehört dem Server, nicht der Beschriftung.** „Hat
|
||
/// eine Stelle über null" und „ist angefangen" waren dasselbe, bis es eine
|
||
/// einstellbare Untergrenze gab. Seitdem heisst ein Titel, in den jemand
|
||
/// eine Sekunde hineingeschaut hat, nicht mehr „Weiterschauen" — er steht
|
||
/// ja auch nicht mehr in der Reihe.
|
||
///
|
||
/// `nil` heisst: der Server sagt nichts dazu (Jellyfin, oder ein älterer
|
||
/// Flaro). Dann gilt wie früher die blosse Stelle.
|
||
public let isInProgress: Bool?
|
||
|
||
/// Ab wann es den Titel gibt — `nil` heisst: jetzt.
|
||
///
|
||
/// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das
|
||
/// ist der Sinn des Planens: man soll sehen, dass er kommt. Ohne dieses
|
||
/// Feld bot die App „Abspielen" an und bekam vom Server eine Absage —
|
||
/// ein Knopf, der ein Versprechen gibt und es zurücknimmt.
|
||
public let availableFrom: Date?
|
||
|
||
// Zählwerte bei Serien und Staffeln.
|
||
public let episodeCount: Int?
|
||
public let playedEpisodeCount: Int?
|
||
public let childCount: Int?
|
||
|
||
/// Die Fassungen, in denen der Titel vorliegt.
|
||
public let versions: [MediaVersion]
|
||
|
||
// Schlagwörter. Beide Server liefern sie nur bei der Einzelabfrage mit,
|
||
// nicht in Listen — dort sind sie deshalb leer.
|
||
public let genres: [String]
|
||
public let directors: [String]
|
||
public let writers: [String]
|
||
public let cast: [MediaPerson]
|
||
public let markers: [MediaMarker]
|
||
/// Zusätzliche Bilder, etwa der Schriftzug eines Films.
|
||
public let artwork: [MediaArtwork]
|
||
|
||
public init(
|
||
id: String,
|
||
path: String? = nil,
|
||
kind: Kind? = nil,
|
||
title: String,
|
||
sortTitle: String? = nil,
|
||
summary: String? = nil,
|
||
year: Int? = nil,
|
||
duration: Int? = nil,
|
||
contentRating: String? = nil,
|
||
rating: Double? = nil,
|
||
audienceRating: Double? = nil,
|
||
thumb: String? = nil,
|
||
art: String? = nil,
|
||
parentThumb: String? = nil,
|
||
seriesThumb: String? = nil,
|
||
seriesArt: String? = nil,
|
||
heroArt: String? = nil,
|
||
index: Int? = nil,
|
||
parentIndex: Int? = nil,
|
||
parentTitle: String? = nil,
|
||
parentID: String? = nil,
|
||
seriesTitle: String? = nil,
|
||
seriesID: String? = nil,
|
||
playCount: Int? = nil,
|
||
resumeMilliseconds: Int? = nil,
|
||
lastPlayedAt: Date? = nil,
|
||
addedAt: Date? = nil,
|
||
isFavourite: Bool = false,
|
||
isInProgress: Bool? = nil,
|
||
availableFrom: Date? = nil,
|
||
episodeCount: Int? = nil,
|
||
playedEpisodeCount: Int? = nil,
|
||
childCount: Int? = nil,
|
||
versions: [MediaVersion] = [],
|
||
genres: [String] = [],
|
||
directors: [String] = [],
|
||
writers: [String] = [],
|
||
cast: [MediaPerson] = [],
|
||
markers: [MediaMarker] = [],
|
||
artwork: [MediaArtwork] = []
|
||
) {
|
||
self.id = id
|
||
self.path = path
|
||
self.kind = kind
|
||
self.title = title
|
||
self.sortTitle = sortTitle
|
||
self.summary = summary
|
||
self.year = year
|
||
self.duration = duration
|
||
self.contentRating = contentRating
|
||
self.rating = rating
|
||
self.audienceRating = audienceRating
|
||
self.thumb = thumb
|
||
self.art = art
|
||
self.parentThumb = parentThumb
|
||
self.seriesThumb = seriesThumb
|
||
self.seriesArt = seriesArt
|
||
self.heroArt = heroArt
|
||
self.index = index
|
||
self.parentIndex = parentIndex
|
||
self.parentTitle = parentTitle
|
||
self.parentID = parentID
|
||
self.seriesTitle = seriesTitle
|
||
self.seriesID = seriesID
|
||
self.playCount = playCount
|
||
self.resumeMilliseconds = resumeMilliseconds
|
||
self.lastPlayedAt = lastPlayedAt
|
||
self.addedAt = addedAt
|
||
self.isFavourite = isFavourite
|
||
self.isInProgress = isInProgress
|
||
self.availableFrom = availableFrom
|
||
self.episodeCount = episodeCount
|
||
self.playedEpisodeCount = playedEpisodeCount
|
||
self.childCount = childCount
|
||
self.versions = versions
|
||
self.genres = genres
|
||
self.directors = directors
|
||
self.writers = writers
|
||
self.cast = cast
|
||
self.markers = markers
|
||
self.artwork = artwork
|
||
}
|
||
}
|
||
|
||
extension MediaItem {
|
||
/// Dasselbe ohne alles, was eine Kachel nicht zeigt.
|
||
///
|
||
/// **Wozu.** Ein Titel aus einer Listenabfrage bringt seine Fassungen mit,
|
||
/// und die bringen jede Spur jeder Datei mit — Codec, Sprache, Kanäle. Im
|
||
/// Raster steht davon nichts: dort stehen Plakat, Titel, Untertitel und
|
||
/// der Fortschrittsbalken. Gespeichert wäre der Rest das Zwanzigfache an
|
||
/// Daten, die bei jedem Aufrufen gelesen und ausgepackt werden müssten —
|
||
/// und damit wäre der Speicher genau das nicht mehr, wofür es ihn gibt.
|
||
///
|
||
/// **Warum das gefahrlos ist.** Aus einer Kachel führt nur ein Weg, und
|
||
/// der geht über die Kennung: die Detailansicht holt den Titel neu. Was
|
||
/// hier fehlt, fehlt nur so lange, bis der Server geantwortet hat.
|
||
var listEssentials: MediaItem {
|
||
MediaItem(
|
||
id: id,
|
||
path: path,
|
||
kind: kind,
|
||
title: title,
|
||
sortTitle: sortTitle,
|
||
year: year,
|
||
duration: duration,
|
||
contentRating: contentRating,
|
||
thumb: thumb,
|
||
art: art,
|
||
parentThumb: parentThumb,
|
||
seriesThumb: seriesThumb,
|
||
index: index,
|
||
parentIndex: parentIndex,
|
||
parentTitle: parentTitle,
|
||
parentID: parentID,
|
||
seriesTitle: seriesTitle,
|
||
seriesID: seriesID,
|
||
playCount: playCount,
|
||
resumeMilliseconds: resumeMilliseconds,
|
||
lastPlayedAt: lastPlayedAt,
|
||
addedAt: addedAt,
|
||
isFavourite: isFavourite,
|
||
episodeCount: episodeCount,
|
||
playedEpisodeCount: playedEpisodeCount,
|
||
childCount: childCount
|
||
)
|
||
}
|
||
|
||
/// Wie weit gesehen — als Anteil zwischen 0 und 1.
|
||
///
|
||
/// `nil`, wenn noch nicht begonnen wurde: ein Balken bei null Prozent
|
||
/// sieht aus wie ein Fehler, nicht wie „noch nicht angefangen".
|
||
public var watchProgress: Double? {
|
||
guard let resumeMilliseconds, let duration, duration > 0, resumeMilliseconds > 0 else {
|
||
return nil
|
||
}
|
||
return min(Double(resumeMilliseconds) / Double(duration), 1)
|
||
}
|
||
|
||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
||
|
||
/// Ob die Beschriftung „Weiterschauen" heissen soll.
|
||
///
|
||
/// Eine Stelle, die beide Fälle kennt: was der Server sagt, und was ohne
|
||
/// seine Auskunft gilt. Stünde der Zweifel an jedem Knopf einzeln, hiesse
|
||
/// derselbe Titel auf der Startseite anders als auf seiner Seite.
|
||
public var isResumable: Bool { isInProgress ?? (watchProgress != nil) }
|
||
|
||
/// Der Balken unter der Kachel — nur, wenn der Titel wirklich angefangen ist.
|
||
///
|
||
/// **Sonst sagt die Kachel etwas anderes als der Knopf.** Ein Strich unter
|
||
/// dem Plakat heisst „du bist mittendrin". Für eine Sekunde, in die jemand
|
||
/// hineingeschaut hat, ist das keine Auskunft, sondern ein Fussel.
|
||
public var resumeProgress: Double? { isResumable ? watchProgress : nil }
|
||
|
||
/// Ob der Titel erst noch kommt.
|
||
///
|
||
/// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an
|
||
/// einem Bildschirm „Abspielen" und am nächsten der Termin.
|
||
public var isUpcoming: Bool {
|
||
guard let availableFrom else { return false }
|
||
return availableFrom > Date()
|
||
}
|
||
|
||
/// Ab wo fortgesetzt wird.
|
||
public var resumeTime: Duration {
|
||
.milliseconds(resumeMilliseconds ?? 0)
|
||
}
|
||
|
||
/// Die Fassung, die gespielt werden soll, wenn niemand wählt.
|
||
public var preferredVersion: MediaVersion? { versions.first }
|
||
|
||
public var hasMultipleVersions: Bool { versions.count > 1 }
|
||
|
||
/// Der Schriftzug des Films, falls der Server einen hat.
|
||
///
|
||
/// Viele Filme bringen ihr eigenes Logo mit — Harry Potter in seiner
|
||
/// Schrift sagt mehr als derselbe Titel in der Systemschrift.
|
||
public var clearLogoPath: String? {
|
||
artwork.first { $0.kind == .clearLogo }?.path
|
||
}
|
||
}
|
||
|
||
/// Jemand, der mitgewirkt hat.
|
||
public struct MediaPerson: Sendable, Equatable, Identifiable, Codable {
|
||
public let id: String
|
||
public let name: String
|
||
/// Die Rolle, sofern es eine gibt.
|
||
public let role: String?
|
||
public let thumb: String?
|
||
|
||
public init(id: String, name: String, role: String? = nil, thumb: String? = nil) {
|
||
self.id = id
|
||
self.name = name
|
||
self.role = role
|
||
self.thumb = thumb
|
||
}
|
||
}
|
||
|
||
/// Ein Abschnitt, den man überspringen möchte.
|
||
public struct MediaMarker: Sendable, Equatable, Identifiable, Codable {
|
||
public enum Kind: String, Sendable, Codable {
|
||
case intro, credits, commercial, unknown
|
||
}
|
||
|
||
public let kind: Kind
|
||
public let startMilliseconds: Int
|
||
public let endMilliseconds: Int
|
||
|
||
public init(kind: Kind, startMilliseconds: Int, endMilliseconds: Int) {
|
||
self.kind = kind
|
||
self.startMilliseconds = startMilliseconds
|
||
self.endMilliseconds = endMilliseconds
|
||
}
|
||
|
||
public func contains(_ milliseconds: Int) -> Bool {
|
||
milliseconds >= startMilliseconds && milliseconds < endMilliseconds
|
||
}
|
||
|
||
/// Der Anfang taugt als Kennung.
|
||
///
|
||
/// Zwei Marken derselben Sendung können nicht an derselben Stelle
|
||
/// beginnen, und der Player braucht die Kennung nur, um sich zu merken,
|
||
/// was er schon übersprungen hat — ohne dieses Gedächtnis spränge ein
|
||
/// Rücksprung ins Intro es sofort wieder weg.
|
||
public var id: Int { startMilliseconds }
|
||
|
||
public var isIntro: Bool { kind == .intro }
|
||
public var isCredits: Bool { kind == .credits }
|
||
}
|
||
|
||
/// Ein zusätzliches Bild zu einem Titel.
|
||
public struct MediaArtwork: Sendable, Equatable, Codable {
|
||
public enum Kind: String, Sendable, Codable {
|
||
case poster, background, banner, clearLogo, unknown
|
||
}
|
||
|
||
public let kind: Kind
|
||
public let path: String
|
||
|
||
public init(kind: Kind, path: String) {
|
||
self.kind = kind
|
||
self.path = path
|
||
}
|
||
}
|