Vier Punkte aus dem Praxistest
Download-Knopf tat nichts DownloadService.plan gab still nil zurück, wenn es keinen Auftrag bauen konnte, und download() reichte das als nil weiter. Der Knopf sah aus, als wäre er kaputt, dabei war der Grund jedes Mal bekannt. Es gibt jetzt einen eigenen Ablehnungsgrund mit vier unterschiedlichen Begründungen — keine Verbindung, keine Datei, kein abrufbarer Teil, keine Adresse. Eine Funktion, die kommentarlos nichts tut, ist selbst der Fehler. Live-TV galt als „nicht eingerichtet" Plex legt die Empfängerliste unter dem Schlüssel `Dvr` ab, die Hülle kannte aber nur Metadata, Directory und Hub. Sie las eine leere Liste und zog daraus die falsche Schlussfolgerung — auf einem Server, auf dem ein Empfänger eingerichtet war. Dasselbe galt für die Anbieterliste unter `MediaProvider`, ohne die es keinen Programmführer gibt. Beim Umbau wäre beinahe die Fehlerdiagnose verloren gegangen: mit `try?` über alle Schlüssel verschwände auch ein echter Dekodierfehler und ergäbe stillschweigend eine leere Liste. Aus „ein Feld heißt jetzt anders" würde „die Mediathek ist leer". Jetzt wird erst geprüft, ob der Schlüssel da ist, und dann ohne try? dekodiert. Fünf Tests halten beides fest. Qualität im Player wählbar Automatisch, Original, 1080p, 720p, 480p. Der Eingriff wird genau dann gebraucht, wenn es ruckelt — dafür will niemand den Film verlassen. Ein Wechsel baut den Strom neu auf (die Stufe steckt in der Adresse), merkt sich die Stelle und fährt sie wieder an. Der alte Transcoder wird vorher angehalten, sonst rechnet der Server zwei Ströme für einen Zuschauer. AVPlayer-Anzeige entfernt Sie war eine Prüfhilfe aus Phase 1. Dieselbe Angabe steht weiterhin unter Einstellungen → Diagnose, wo sie hingehört. 137 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2991,6 +2991,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"signIn.timedOut": {
|
||||||
|
"comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen",
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sign-in wasn't completed. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"signIn.windowFailed": {
|
"signIn.windowFailed": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -3414,19 +3432,146 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signIn.timedOut": {
|
"downloads.refused.noServer": {
|
||||||
"comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal."
|
"value": "Keine Verbindung zum Server."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Sign-in wasn't completed. Please try again."
|
"value": "No connection to the server."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads.refused.noMedia": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zu diesem Titel liegt keine Datei auf dem Server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "There's no file for this title on the server."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads.refused.noPart": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Die Datei auf dem Server hat keinen abrufbaren Teil. Möglicherweise wurde sie noch nicht analysiert."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The file on the server has no retrievable part. It may not have been analyzed yet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads.refused.noURL": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Die Download-Adresse ließ sich nicht bilden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The download address couldn't be built."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"player.quality": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Qualität"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Quality"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality.automatic": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Automatisch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Automatic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality.fullHD": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Full HD · 1080p"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Full HD · 1080p"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality.hd": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "HD · 720p"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "HD · 720p"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality.lowRes": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sparsam · 480p"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Data saver · 480p"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ struct DownloadRefiller {
|
|||||||
// ließe sich weder das Speicherlimit prüfen noch ein
|
// ließe sich weder das Speicherlimit prüfen noch ein
|
||||||
// Fortschritt anzeigen.
|
// Fortschritt anzeigen.
|
||||||
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
|
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
|
||||||
guard let plan = service.plan(for: detailed) else { continue }
|
// Folgen ohne abrufbare Datei still überspringen: das Nachladen
|
||||||
|
// läuft im Hintergrund, da hilft keine Fehlermeldung.
|
||||||
|
guard let plan = try? service.plan(for: detailed) else { continue }
|
||||||
if (try? coordinator.enqueue(plan)) != nil { added += 1 }
|
if (try? coordinator.enqueue(plan)) != nil { added += 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,16 +14,26 @@ struct DownloadService {
|
|||||||
|
|
||||||
/// Baut den Auftrag für einen einzelnen Titel.
|
/// Baut den Auftrag für einen einzelnen Titel.
|
||||||
///
|
///
|
||||||
/// `nil`, wenn es keine abspielbare Datei gibt — bei einem Titel, den der
|
/// Wirft mit Begründung, statt still `nil` zurückzugeben. Der Unterschied
|
||||||
/// Server noch nicht analysiert hat, etwa.
|
/// ist nicht akademisch: mit `nil` sah der Download-Knopf aus, als täte er
|
||||||
func plan(for item: PlexItem, mediaIndex: Int = 0) -> DownloadPlan? {
|
/// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server
|
||||||
guard let server else { return nil }
|
/// noch nicht analysiert hat.
|
||||||
|
func plan(for item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadPlan {
|
||||||
|
guard let server else {
|
||||||
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
|
||||||
|
}
|
||||||
let media = item.media.indices.contains(mediaIndex)
|
let media = item.media.indices.contains(mediaIndex)
|
||||||
? item.media[mediaIndex]
|
? item.media[mediaIndex]
|
||||||
: item.preferredMedia
|
: item.preferredMedia
|
||||||
guard let media, let part = media.parts.first,
|
guard let media else {
|
||||||
let url = server.downloadURL(part: part)
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia"))
|
||||||
else { return nil }
|
}
|
||||||
|
guard let part = media.parts.first else {
|
||||||
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
|
||||||
|
}
|
||||||
|
guard let url = server.downloadURL(part: part) else {
|
||||||
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noURL"))
|
||||||
|
}
|
||||||
|
|
||||||
return DownloadPlan(
|
return DownloadPlan(
|
||||||
ratingKey: item.ratingKey,
|
ratingKey: item.ratingKey,
|
||||||
@@ -43,9 +53,8 @@ struct DownloadService {
|
|||||||
|
|
||||||
/// Lädt einen einzelnen Titel.
|
/// Lädt einen einzelnen Titel.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func download(_ item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadItem? {
|
func download(_ item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadItem {
|
||||||
guard let plan = plan(for: item, mediaIndex: mediaIndex) else { return nil }
|
try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex))
|
||||||
return try coordinator.enqueue(plan)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt alle Folgen einer Staffel oder Serie.
|
/// Lädt alle Folgen einer Staffel oder Serie.
|
||||||
@@ -78,14 +87,14 @@ struct DownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
|
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
|
||||||
guard let plan = plan(for: detailed) else {
|
|
||||||
unavailable += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try coordinator.enqueue(plan)
|
_ = try coordinator.enqueue(try plan(for: detailed))
|
||||||
accepted += 1
|
accepted += 1
|
||||||
|
} catch DownloadRefusal.noDownloadableFile {
|
||||||
|
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
||||||
|
// werden am Ende nur gezählt.
|
||||||
|
unavailable += 1
|
||||||
} catch {
|
} catch {
|
||||||
refused.append((episode, error))
|
refused.append((episode, error))
|
||||||
// Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen
|
// Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ extension DownloadRefusal {
|
|||||||
String(localized: "downloads.refused.exists")
|
String(localized: "downloads.refused.exists")
|
||||||
case .storageLimitReached(let limit, let used):
|
case .storageLimitReached(let limit, let used):
|
||||||
String(localized: "downloads.refused.limit \(used.formatted(.byteCount(style: .file))) \(limit.formatted(.byteCount(style: .file)))")
|
String(localized: "downloads.refused.limit \(used.formatted(.byteCount(style: .file))) \(limit.formatted(.byteCount(style: .file)))")
|
||||||
|
case .noDownloadableFile(let reason):
|
||||||
|
reason
|
||||||
case .insufficientDeviceSpace(let required, let available):
|
case .insufficientDeviceSpace(let required, let available):
|
||||||
String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))")
|
String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,15 @@ struct LiveTVView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .available where channels.isEmpty:
|
case .available where channels.isEmpty:
|
||||||
|
// Empfänger gefunden, aber keine Sender. Ein anderer
|
||||||
|
// Zustand als „nicht eingerichtet", und die Unterscheidung
|
||||||
|
// zählt: sie sagt, ob es am Server liegt oder an der App.
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("liveTV.noChannels.title", systemImage: "tv")
|
Label("liveTV.noChannels.title", systemImage: "tv")
|
||||||
} description: {
|
} description: {
|
||||||
Text("liveTV.noChannels.description")
|
Text("liveTV.noChannels.description")
|
||||||
|
} actions: {
|
||||||
|
Button("action.retry") { Task { await load() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
case .available:
|
case .available:
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ final class PlayerModel {
|
|||||||
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
||||||
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
||||||
private(set) var isOffline = false
|
private(set) var isOffline = false
|
||||||
|
/// Die im Player gewählte Stufe. `automatic` folgt den Einstellungen.
|
||||||
|
private(set) var quality: PlayerQuality = .automatic
|
||||||
|
/// Ob gerade auf eine andere Stufe umgestellt wird.
|
||||||
|
private(set) var isChangingQuality = false
|
||||||
|
|
||||||
private let request: PlaybackRequest
|
private let request: PlaybackRequest
|
||||||
private let server: PlexServer?
|
private let server: PlexServer?
|
||||||
@@ -55,6 +59,12 @@ final class PlayerModel {
|
|||||||
private let isLocalConnection: Bool
|
private let isLocalConnection: Bool
|
||||||
|
|
||||||
private var item: PlexItem?
|
private var item: PlexItem?
|
||||||
|
/// Die gerade gespielte Fassung und Datei.
|
||||||
|
///
|
||||||
|
/// Festgehalten, damit ein Qualitätswechsel die Quelle neu bauen kann,
|
||||||
|
/// ohne die Metadaten erneut zu holen.
|
||||||
|
private var currentMedia: PlexMedia?
|
||||||
|
private var currentPart: PlexPart?
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
private var reportTask: Task<Void, Never>?
|
private var reportTask: Task<Void, Never>?
|
||||||
private var transcodeSession: String?
|
private var transcodeSession: String?
|
||||||
@@ -120,6 +130,9 @@ final class PlayerModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentMedia = media
|
||||||
|
currentPart = part
|
||||||
|
|
||||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
|
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
|
||||||
self.decision = decision
|
self.decision = decision
|
||||||
|
|
||||||
@@ -132,6 +145,53 @@ final class PlayerModel {
|
|||||||
preloadNextEpisode()
|
preloadNextEpisode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stellt auf eine andere Qualitätsstufe um.
|
||||||
|
///
|
||||||
|
/// Bedeutet zwangsläufig einen Neuaufbau: die Stufe steckt in der Adresse
|
||||||
|
/// des Stroms, nicht in einer Einstellung, die sich unterwegs drehen ließe.
|
||||||
|
/// Die Stelle wird gemerkt und danach wieder angefahren — sonst begänne der
|
||||||
|
/// Film bei jedem Wechsel von vorn.
|
||||||
|
func select(quality newQuality: PlayerQuality) async {
|
||||||
|
guard newQuality != quality, !isOffline,
|
||||||
|
let server, let item, let media = currentMedia, let part = currentPart
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
isChangingQuality = true
|
||||||
|
defer { isChangingQuality = false }
|
||||||
|
|
||||||
|
let resumeAt = currentTime
|
||||||
|
quality = newQuality
|
||||||
|
|
||||||
|
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
||||||
|
// der Server zwei Ströme gleichzeitig für einen Zuschauer.
|
||||||
|
if let transcodeSession {
|
||||||
|
await server.stopTranscode(sessionIdentifier: transcodeSession)
|
||||||
|
self.transcodeSession = nil
|
||||||
|
}
|
||||||
|
eventTask?.cancel()
|
||||||
|
engine?.teardown()
|
||||||
|
engine = nil
|
||||||
|
phase = .preparing
|
||||||
|
|
||||||
|
let newPolicy = newQuality.policy(basedOn: policy)
|
||||||
|
let newDecision = PlaybackDecider().decide(media: media, part: part, policy: newPolicy)
|
||||||
|
decision = newDecision
|
||||||
|
|
||||||
|
guard let source = await source(
|
||||||
|
for: newDecision,
|
||||||
|
item: item,
|
||||||
|
media: media,
|
||||||
|
part: part,
|
||||||
|
server: server,
|
||||||
|
startingAt: resumeAt
|
||||||
|
) else {
|
||||||
|
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await load(source, decision: newDecision)
|
||||||
|
}
|
||||||
|
|
||||||
/// Wiedergabe aus dem Download-Ordner.
|
/// Wiedergabe aus dem Download-Ordner.
|
||||||
///
|
///
|
||||||
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
|
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
|
||||||
@@ -206,18 +266,22 @@ final class PlayerModel {
|
|||||||
item: PlexItem,
|
item: PlexItem,
|
||||||
media: PlexMedia,
|
media: PlexMedia,
|
||||||
part: PlexPart,
|
part: PlexPart,
|
||||||
server: PlexServer
|
server: PlexServer,
|
||||||
|
startingAt: Duration? = nil
|
||||||
) async -> PlaybackSource? {
|
) async -> PlaybackSource? {
|
||||||
|
let start = startingAt ?? request.startTime
|
||||||
|
|
||||||
switch decision.method {
|
switch decision.method {
|
||||||
case .directPlay:
|
case .directPlay:
|
||||||
guard let url = server.directPlayURL(part: part) else { return nil }
|
guard let url = server.directPlayURL(part: part) else { return nil }
|
||||||
return PlaybackSource(url: url, origin: .remote, startTime: request.startTime)
|
return PlaybackSource(url: url, origin: .remote, startTime: start)
|
||||||
|
|
||||||
case .transcode(let maximumBitrate):
|
case .transcode(let maximumBitrate):
|
||||||
let transcode = TranscodeRequest(
|
let transcode = TranscodeRequest(
|
||||||
ratingKey: item.ratingKey,
|
ratingKey: item.ratingKey,
|
||||||
mediaIndex: request.mediaIndex,
|
mediaIndex: request.mediaIndex,
|
||||||
maximumBitrate: maximumBitrate,
|
maximumBitrate: maximumBitrate,
|
||||||
|
resolution: quality.resolution,
|
||||||
isLocal: isLocalConnection
|
isLocal: isLocalConnection
|
||||||
)
|
)
|
||||||
transcodeSession = transcode.sessionIdentifier
|
transcodeSession = transcode.sessionIdentifier
|
||||||
@@ -232,7 +296,7 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let url = server.transcodeURL(for: transcode) else { return nil }
|
guard let url = server.transcodeURL(for: transcode) else { return nil }
|
||||||
return PlaybackSource(url: url, origin: .remote, startTime: request.startTime)
|
return PlaybackSource(url: url, origin: .remote, startTime: start)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,13 +153,44 @@ struct PlayerView: View {
|
|||||||
.accessibilityLabel(Text("player.tracks"))
|
.accessibilityLabel(Text("player.tracks"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let decision = model.decision {
|
if !model.isOffline {
|
||||||
PlaybackMethodBadge(decision: decision)
|
qualityMenu(for: model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Qualitätsstufe für diesen Film.
|
||||||
|
///
|
||||||
|
/// Im Player und nicht nur in den Einstellungen, weil der Eingriff genau
|
||||||
|
/// dann gebraucht wird, wenn es gerade ruckelt — und dafür will niemand
|
||||||
|
/// den Film verlassen.
|
||||||
|
private func qualityMenu(for model: PlayerModel) -> some View {
|
||||||
|
Menu {
|
||||||
|
Picker("player.quality", selection: Binding(
|
||||||
|
get: { model.quality },
|
||||||
|
set: { selected in
|
||||||
|
Task { await model.select(quality: selected) }
|
||||||
|
scheduleControlsHide()
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ForEach(PlayerQuality.allCases) { option in
|
||||||
|
Text(option.label).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if model.isChangingQuality {
|
||||||
|
ProgressView().controlSize(.small).tint(.white)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.title3)
|
||||||
|
.padding(FlixySpacing.snug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(model.isChangingQuality)
|
||||||
|
.accessibilityLabel(Text("player.quality"))
|
||||||
|
}
|
||||||
|
|
||||||
private func transport(for model: PlayerModel) -> some View {
|
private func transport(for model: PlayerModel) -> some View {
|
||||||
HStack(spacing: FlixySpacing.section * 2) {
|
HStack(spacing: FlixySpacing.section * 2) {
|
||||||
Button {
|
Button {
|
||||||
@@ -337,32 +368,6 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zeigt an, auf welchem Weg gerade abgespielt wird.
|
|
||||||
struct PlaybackMethodBadge: View {
|
|
||||||
let decision: PlaybackDecision
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(label)
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(.white.opacity(0.75))
|
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
|
||||||
.padding(.vertical, FlixySpacing.tight)
|
|
||||||
.background(.white.opacity(0.15), in: .capsule)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var label: String {
|
|
||||||
switch decision.method {
|
|
||||||
case .directPlay(let engine):
|
|
||||||
switch engine {
|
|
||||||
case .avPlayer: "AVPlayer"
|
|
||||||
case .vlc: "VLC"
|
|
||||||
}
|
|
||||||
case .transcode:
|
|
||||||
"Transcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Duration {
|
extension Duration {
|
||||||
/// Als „1:23:45" bzw. „23:45".
|
/// Als „1:23:45" bzw. „23:45".
|
||||||
var timecode: String {
|
var timecode: String {
|
||||||
@@ -376,3 +381,15 @@ extension Duration {
|
|||||||
return String(format: "%d:%02d", minutes, seconds)
|
return String(format: "%d:%02d", minutes, seconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension PlayerQuality {
|
||||||
|
var label: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .automatic: "quality.automatic"
|
||||||
|
case .original: "quality.original"
|
||||||
|
case .fullHD: "quality.fullHD"
|
||||||
|
case .hd: "quality.hd"
|
||||||
|
case .low: "quality.lowRes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ public struct DownloadPlan: Sendable {
|
|||||||
/// Warum ein Download nicht angenommen wurde.
|
/// Warum ein Download nicht angenommen wurde.
|
||||||
public enum DownloadRefusal: Error, Equatable, Sendable {
|
public enum DownloadRefusal: Error, Equatable, Sendable {
|
||||||
case alreadyExists
|
case alreadyExists
|
||||||
|
/// Zu diesem Titel gibt es keine Datei, die sich herunterladen ließe.
|
||||||
|
///
|
||||||
|
/// Eigener Fall statt eines stillen `nil`: eine Funktion, die kommentarlos
|
||||||
|
/// nichts tut, ist selbst der Fehler — der Knopf sah aus, als wäre er
|
||||||
|
/// kaputt, dabei fehlte nur die Angabe, woran es liegt.
|
||||||
|
case noDownloadableFile(reason: String)
|
||||||
/// Das eingestellte Speicherlimit wäre überschritten.
|
/// Das eingestellte Speicherlimit wäre überschritten.
|
||||||
case storageLimitReached(limitBytes: Int64, usedBytes: Int64)
|
case storageLimitReached(limitBytes: Int64, usedBytes: Int64)
|
||||||
/// Auf dem Gerät ist zu wenig Platz.
|
/// Auf dem Gerät ist zu wenig Platz.
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Eine im Player wählbare Qualitätsstufe.
|
||||||
|
///
|
||||||
|
/// Getrennt von den Einstellungen unter „Qualität": dort steht, was
|
||||||
|
/// üblicherweise gelten soll; hier greift man für diesen einen Film ein — weil
|
||||||
|
/// es gerade ruckelt oder weil man ausnahmsweise das Original will.
|
||||||
|
public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable {
|
||||||
|
/// Was in den Einstellungen für die aktuelle Verbindung hinterlegt ist.
|
||||||
|
case automatic
|
||||||
|
/// Die Datei unverändert, ohne Umrechnung.
|
||||||
|
case original
|
||||||
|
case fullHD
|
||||||
|
case hd
|
||||||
|
case low
|
||||||
|
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
/// Obergrenze in Kilobit. `nil` bei `automatic` und `original`.
|
||||||
|
public var maximumBitrate: Int? {
|
||||||
|
switch self {
|
||||||
|
case .automatic, .original: nil
|
||||||
|
case .fullHD: 8_000
|
||||||
|
case .hd: 4_000
|
||||||
|
case .low: 2_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var resolution: String? {
|
||||||
|
switch self {
|
||||||
|
case .automatic, .original: nil
|
||||||
|
case .fullHD: "1920x1080"
|
||||||
|
case .hd: "1280x720"
|
||||||
|
case .low: "854x480"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob diese Stufe eine Umrechnung erzwingt.
|
||||||
|
///
|
||||||
|
/// `original` erzwingt das Gegenteil: keine Umrechnung, auch wenn die
|
||||||
|
/// Einstellungen eine Grenze vorsehen.
|
||||||
|
public var forcesTranscode: Bool {
|
||||||
|
switch self {
|
||||||
|
case .automatic, .original: false
|
||||||
|
case .fullHD, .hd, .low: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Richtlinie für diese Stufe.
|
||||||
|
///
|
||||||
|
/// `automatic` reicht durch, was ohnehin gilt. Die übrigen überschreiben
|
||||||
|
/// sie — das ist der Zweck eines Eingriffs im Player.
|
||||||
|
public func policy(basedOn base: PlaybackPolicy) -> PlaybackPolicy {
|
||||||
|
switch self {
|
||||||
|
case .automatic:
|
||||||
|
base
|
||||||
|
case .original:
|
||||||
|
// Keine Grenze und kein Umrechnen: ausdrücklich das Original.
|
||||||
|
PlaybackPolicy(allowsTranscoding: false, allowsFallbackEngine: true, maximumBitrate: nil)
|
||||||
|
case .fullHD, .hd, .low:
|
||||||
|
// Ohne Ersatzmotor, damit die Entscheidung zwangsläufig beim
|
||||||
|
// Transcoder landet — sonst spielte VLC die Datei direkt ab und
|
||||||
|
// die gewählte Stufe bliebe wirkungslos.
|
||||||
|
PlaybackPolicy(
|
||||||
|
allowsTranscoding: true,
|
||||||
|
allowsFallbackEngine: false,
|
||||||
|
maximumBitrate: maximumBitrate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,35 +179,55 @@ public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decod
|
|||||||
public let title2: String?
|
public let title2: String?
|
||||||
public let content: [Content]
|
public let content: [Content]
|
||||||
|
|
||||||
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
|
/// Unter welchen Namen Plex seine Sammlungen ablegt.
|
||||||
private enum InnerKeys: String, CodingKey {
|
///
|
||||||
case size, totalSize, offset, title1, title2
|
/// Es gibt keinen einheitlichen: Titel stehen unter `Metadata`,
|
||||||
case metadata = "Metadata"
|
/// Mediatheken unter `Directory`, die Reihen der Startseite unter `Hub`,
|
||||||
case directory = "Directory"
|
/// Fernsehempfänger unter `Dvr`, die Anbieterliste unter `MediaProvider`.
|
||||||
case hub = "Hub"
|
///
|
||||||
|
/// Genau daran war Live-TV gescheitert: die Hülle kannte `Dvr` nicht, las
|
||||||
|
/// eine leere Liste und meldete „kein Empfänger eingerichtet" — bei einem
|
||||||
|
/// Server, auf dem einer eingerichtet war.
|
||||||
|
///
|
||||||
|
/// Die Namen schließen sich gegenseitig aus, deshalb genügt es, sie der
|
||||||
|
/// Reihe nach zu probieren.
|
||||||
|
private static var contentKeys: [String] {
|
||||||
|
["Metadata", "Directory", "Hub", "Dvr", "MediaProvider", "Channel", "ChannelMapping", "Device"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct DynamicKey: CodingKey {
|
||||||
|
let stringValue: String
|
||||||
|
var intValue: Int? { nil }
|
||||||
|
init(_ value: String) { stringValue = value }
|
||||||
|
init?(stringValue: String) { self.stringValue = stringValue }
|
||||||
|
init?(intValue: Int) { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
public init(from decoder: any Decoder) throws {
|
||||||
let outer = try decoder.container(keyedBy: OuterKeys.self)
|
let outer = try decoder.container(keyedBy: OuterKeys.self)
|
||||||
let inner = try outer.nestedContainer(keyedBy: InnerKeys.self, forKey: .mediaContainer)
|
let inner = try outer.nestedContainer(keyedBy: DynamicKey.self, forKey: .mediaContainer)
|
||||||
|
|
||||||
self.size = try inner.decodeIfPresent(Int.self, forKey: .size)
|
self.size = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("size"))
|
||||||
self.totalSize = try inner.decodeIfPresent(Int.self, forKey: .totalSize)
|
self.totalSize = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("totalSize"))
|
||||||
self.offset = try inner.decodeIfPresent(Int.self, forKey: .offset)
|
self.offset = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("offset"))
|
||||||
self.title1 = try inner.decodeIfPresent(String.self, forKey: .title1)
|
self.title1 = try inner.decodeIfPresent(String.self, forKey: DynamicKey("title1"))
|
||||||
self.title2 = try inner.decodeIfPresent(String.self, forKey: .title2)
|
self.title2 = try inner.decodeIfPresent(String.self, forKey: DynamicKey("title2"))
|
||||||
|
|
||||||
// `Metadata` bei Titeln, `Directory` bei Mediatheken, `Hub` bei den
|
// Erst nachsehen, ob der Schlüssel überhaupt da ist, und dann ohne
|
||||||
// Reihen der Startseite. Eine leere Mediathek liefert gar kein Feld —
|
// `try?` dekodieren.
|
||||||
// das ist kein Fehler, sondern bedeutet schlicht null Einträge.
|
//
|
||||||
if let metadata = try inner.decodeIfPresent([Content].self, forKey: .metadata) {
|
// Mit `try?` verschwände auch ein echter Dekodierfehler und ergäbe
|
||||||
self.content = metadata
|
// stillschweigend eine leere Liste — aus „ein Feld heißt jetzt anders"
|
||||||
} else if let directory = try inner.decodeIfPresent([Content].self, forKey: .directory) {
|
// würde „die Mediathek ist leer". Genau diese Verwechslung hat bei
|
||||||
self.content = directory
|
// Live-TV schon einmal auf die falsche Fährte geführt.
|
||||||
} else if let hub = try inner.decodeIfPresent([Content].self, forKey: .hub) {
|
for name in Self.contentKeys where inner.contains(DynamicKey(name)) {
|
||||||
self.content = hub
|
self.content = try inner.decode([Content].self, forKey: DynamicKey(name))
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
|
// Kein Schlüssel vorhanden: eine leere Mediathek liefert gar kein Feld.
|
||||||
|
// Das ist kein Fehler, sondern bedeutet null Einträge.
|
||||||
self.content = []
|
self.content = []
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import PlexKit
|
||||||
|
|
||||||
|
/// Unter welchen Namen Plex seine Sammlungen ablegt.
|
||||||
|
///
|
||||||
|
/// Es gibt keinen einheitlichen, und das ist keine Kleinigkeit: Live-TV galt
|
||||||
|
/// als „nicht eingerichtet", weil die Hülle `Dvr` nicht kannte, eine leere
|
||||||
|
/// Liste las und daraus die falsche Schlussfolgerung zog — auf einem Server,
|
||||||
|
/// auf dem ein Empfänger eingerichtet war.
|
||||||
|
@Suite("Sammlungsnamen in der Hülle")
|
||||||
|
struct ContainerKeysTests {
|
||||||
|
|
||||||
|
@Test("Fernsehempfänger stehen unter Dvr")
|
||||||
|
func readsDvrKey() throws {
|
||||||
|
let json = """
|
||||||
|
{"MediaContainer":{"size":1,"Dvr":[
|
||||||
|
{"key":"1","uuid":"abc","lineupTitle":"Kabel","language":"deu"}
|
||||||
|
]}}
|
||||||
|
"""
|
||||||
|
let container = try PlexResponse.decode(
|
||||||
|
PlexMediaContainer<PlexDVR>.self,
|
||||||
|
from: Data(json.utf8)
|
||||||
|
)
|
||||||
|
#expect(container.content.count == 1)
|
||||||
|
#expect(container.content.first?.key == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Anbieterliste steht unter MediaProvider")
|
||||||
|
func readsMediaProviderKey() throws {
|
||||||
|
// Darüber wird die Kennung des Programmführers gefunden. Ohne sie gibt
|
||||||
|
// es kein Programm, auch wenn die Sender da sind.
|
||||||
|
let json = """
|
||||||
|
{"MediaContainer":{"size":2,"MediaProvider":[
|
||||||
|
{"identifier":"com.plexapp.plugins.library","title":"Mediathek"},
|
||||||
|
{"identifier":"tv.plex.providers.epg.cloud:2","title":"Programm"}
|
||||||
|
]}}
|
||||||
|
"""
|
||||||
|
let container = try PlexResponse.decode(
|
||||||
|
PlexMediaContainer<PlexMediaProvider>.self,
|
||||||
|
from: Data(json.utf8)
|
||||||
|
)
|
||||||
|
#expect(container.content.count == 2)
|
||||||
|
#expect(container.content.contains { $0.identifier.contains("epg") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Titel stehen weiterhin unter Metadata")
|
||||||
|
func stillReadsMetadata() throws {
|
||||||
|
let json = #"{"MediaContainer":{"Metadata":[{"ratingKey":"1","type":"movie","title":"Film"}]}}"#
|
||||||
|
let container = try PlexResponse.decode(
|
||||||
|
PlexMediaContainer<PlexItem>.self,
|
||||||
|
from: Data(json.utf8)
|
||||||
|
)
|
||||||
|
#expect(container.content.first?.title == "Film")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine fehlende Sammlung ist kein Fehler")
|
||||||
|
func missingCollectionIsEmpty() throws {
|
||||||
|
// Eine leere Mediathek liefert gar kein Feld.
|
||||||
|
let json = #"{"MediaContainer":{"size":0}}"#
|
||||||
|
let container = try PlexResponse.decode(
|
||||||
|
PlexMediaContainer<PlexItem>.self,
|
||||||
|
from: Data(json.utf8)
|
||||||
|
)
|
||||||
|
#expect(container.content.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine vorhandene, aber kaputte Sammlung meldet den Fehler")
|
||||||
|
func malformedCollectionThrows() {
|
||||||
|
// Der Unterschied, der beim Umbau beinahe verloren ging: aus „ein Feld
|
||||||
|
// heißt jetzt anders" darf nicht „die Mediathek ist leer" werden.
|
||||||
|
let json = #"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"#
|
||||||
|
#expect(throws: PlexError.self) {
|
||||||
|
try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: Data(json.utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user