Files
Flaro/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift
T
ScarriffleandClaude Opus 5 1d70be0f99 Fünf Aufmacher je Titel, und der Abspann zählt als Schluss
**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>
2026-10-12 21:44:43 +02:00

365 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}