Die vier erfundenen Stufen weichen der Leiter, die der Server selbst meldet: 64, 96, 208, 320, 720, 1500, 2000, 3000, 4000, 8000, 10000, 12000, 20000 kbps mit den Auflösungen, die Plex dafür vorsieht. Eine eigene Leiter hieße, Stufen anzubieten, die der Transcoder nicht kennt — er rundet dann still auf die nächste, und die Anzeige log. Angeboten wird nur, was zur Quelle passt. Eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu, und die Stufen sähen alle gleich aus. Oben im Blatt steht, was tatsächlich über die Leitung kommt — gemessene Bitrate, Auflösung, verworfene Bilder. Der Unterschied zur gewählten Stufe ist keine Haarspalterei: die eine sagt, was erlaubt ist, die andere, was ankommt. Ruckelt es trotz hoher Stufe, steht die Antwort dort. „Nur Ton" gibt es, aber ehrlich beschriftet: Plex kennt für Videos keinen reinen Tonstrom, also wird die unterste Sprosse geholt und das Bild ausgeblendet. Dazu Doppeltipp links und rechts für zehn Sekunden zurück und vor. Die Reihenfolge der Gesten ist entscheidend — das einfache Tippen verschluckt sonst das doppelte. Die Kachel hängt nicht mehr in vier Ecken fest, sondern klebt links oder rechts und ist in der Höhe frei. Vier Ecken sind schnell gebaut, aber im Weg: steht sie über einem Knopf, bleiben drei Orte, die ebenso stören. Größer wird sie durch Zusammen- und Auseinanderziehen; der Griff ist weg, er saß zwangsläufig auf dem Bild und stritt sich mit dem Verschieben. Und die Begriffe: „Motor" für engine sagt kein deutscher Entwickler. Fachwörter, die im Deutschen englisch bleiben, bleiben jetzt englisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
5.8 KiB
Swift
162 lines
5.8 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
/// Ein heruntergeladener oder herunterzuladender Titel.
|
|
///
|
|
/// Liegt in SwiftData, weil Downloads die App überleben: iOS überträgt im
|
|
/// Hintergrund weiter, auch wenn die App beendet ist, und beim nächsten Start
|
|
/// muss aus dem gespeicherten Zustand hervorgehen, was zu tun ist.
|
|
@Model
|
|
public final class DownloadItem {
|
|
/// Der Schlüssel des Titels auf dem Server. Eindeutig — derselbe Titel
|
|
/// soll nicht zweimal in der Warteschlange stehen.
|
|
@Attribute(.unique) public var ratingKey: String
|
|
|
|
/// Der Zustand als Zeichenkette. SwiftData kann Aufzählungen speichern,
|
|
/// aber eine Zeichenkette bleibt bei Umbenennungen im Code lesbar — und
|
|
/// ein unlesbarer Zustand hieße: laufende Downloads sind verloren.
|
|
public var stateRaw: String
|
|
|
|
public var title: String
|
|
public var subtitle: String?
|
|
/// Zu welcher Serie und Staffel der Titel gehört. Gruppiert die
|
|
/// Downloads-Ansicht und ist Grundlage für „halte N Folgen offline".
|
|
public var seriesKey: String?
|
|
public var seriesTitle: String?
|
|
public var seasonKey: String?
|
|
public var seasonIndex: Int?
|
|
public var episodeIndex: Int?
|
|
|
|
public var totalBytes: Int64
|
|
public var receivedBytes: Int64
|
|
|
|
/// Dateiname im Download-Ordner. Nicht der volle Pfad: der Container der
|
|
/// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter
|
|
/// absoluter Pfad zeigt danach ins Leere.
|
|
public var localFileName: String?
|
|
public var artworkFileName: String?
|
|
|
|
public var addedAt: Date
|
|
public var completedAt: Date?
|
|
/// Wann der Titel durchgesehen wurde — Grundlage fürs automatische Löschen.
|
|
public var watchedAt: Date?
|
|
public var failureMessage: String?
|
|
|
|
/// Woher geladen wird. Enthält den Token, deshalb liegt er im geschützten
|
|
/// App-Container und nicht in den Voreinstellungen.
|
|
public var sourceURLString: String
|
|
public var durationMilliseconds: Int?
|
|
|
|
/// Was der Player offline über die Datei wissen muss: Container, Codecs,
|
|
/// Spuren, Marken. Als JSON, weil die Form von Plex vorgegeben ist und
|
|
/// sich eigene SwiftData-Modelle dafür nicht lohnen.
|
|
public var offlineMediaData: Data?
|
|
|
|
/// Zum Fortsetzen nach dem Pausieren. iOS liefert diesen Datensatz beim
|
|
/// Abbrechen; ohne ihn beginnt ein pausierter Download wieder bei null.
|
|
public var resumeData: Data?
|
|
|
|
public init(
|
|
ratingKey: String,
|
|
title: String,
|
|
subtitle: String? = nil,
|
|
sourceURLString: String,
|
|
state: DownloadState = .queued,
|
|
totalBytes: Int64 = 0,
|
|
seriesKey: String? = nil,
|
|
seriesTitle: String? = nil,
|
|
seasonKey: String? = nil,
|
|
seasonIndex: Int? = nil,
|
|
episodeIndex: Int? = nil,
|
|
durationMilliseconds: Int? = nil,
|
|
offlineMediaData: Data? = nil
|
|
) {
|
|
self.ratingKey = ratingKey
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.sourceURLString = sourceURLString
|
|
self.stateRaw = state.rawValue
|
|
self.totalBytes = totalBytes
|
|
self.receivedBytes = 0
|
|
self.seriesKey = seriesKey
|
|
self.seriesTitle = seriesTitle
|
|
self.seasonKey = seasonKey
|
|
self.seasonIndex = seasonIndex
|
|
self.episodeIndex = episodeIndex
|
|
self.durationMilliseconds = durationMilliseconds
|
|
self.offlineMediaData = offlineMediaData
|
|
self.addedAt = Date()
|
|
}
|
|
|
|
public var state: DownloadState {
|
|
get { DownloadState(rawValue: stateRaw) ?? .failed }
|
|
set { stateRaw = newValue.rawValue }
|
|
}
|
|
|
|
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
|
|
///
|
|
/// Ein Balken bei null Prozent, der sich nie bewegt, sieht aus wie ein
|
|
/// hängender Download — ohne Gesamtgröße ist ein unbestimmter Fortschritt
|
|
/// die ehrlichere Anzeige.
|
|
public var progress: Double? {
|
|
guard totalBytes > 0 else { return nil }
|
|
return min(Double(receivedBytes) / Double(totalBytes), 1)
|
|
}
|
|
|
|
public var isComplete: Bool { state == .completed }
|
|
|
|
/// Wie viel Platz dieser Eintrag gerade belegt.
|
|
public var occupiedBytes: Int64 {
|
|
state.occupiesStorage ? max(receivedBytes, 0) : 0
|
|
}
|
|
}
|
|
|
|
/// Was der Player offline über eine Datei wissen muss.
|
|
///
|
|
/// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich
|
|
/// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs,
|
|
/// um zwischen die Engines zu wählen.
|
|
public struct OfflineMedia: Sendable, Codable, Equatable {
|
|
public var container: String?
|
|
public var videoCodec: String?
|
|
public var audioCodec: String?
|
|
public var videoResolution: String?
|
|
public var bitrate: Int?
|
|
public var audioLanguages: [String]
|
|
public var subtitleLanguages: [String]
|
|
/// Intro und Abspann, in Millisekunden.
|
|
public var markers: [OfflineMarker]
|
|
|
|
public init(
|
|
container: String? = nil,
|
|
videoCodec: String? = nil,
|
|
audioCodec: String? = nil,
|
|
videoResolution: String? = nil,
|
|
bitrate: Int? = nil,
|
|
audioLanguages: [String] = [],
|
|
subtitleLanguages: [String] = [],
|
|
markers: [OfflineMarker] = []
|
|
) {
|
|
self.container = container
|
|
self.videoCodec = videoCodec
|
|
self.audioCodec = audioCodec
|
|
self.videoResolution = videoResolution
|
|
self.bitrate = bitrate
|
|
self.audioLanguages = audioLanguages
|
|
self.subtitleLanguages = subtitleLanguages
|
|
self.markers = markers
|
|
}
|
|
}
|
|
|
|
public struct OfflineMarker: Sendable, Codable, Equatable {
|
|
public var type: String
|
|
public var startMilliseconds: Int
|
|
public var endMilliseconds: Int
|
|
|
|
public init(type: String, startMilliseconds: Int, endMilliseconds: Int) {
|
|
self.type = type
|
|
self.startMilliseconds = startMilliseconds
|
|
self.endMilliseconds = endMilliseconds
|
|
}
|
|
}
|