Fünf Punkte aus dem Praxistest
iPad meldete sich nie an Die Ursache ist unangenehm speziell: Wer im Browser schon bei Plex angemeldet ist, wird von app.plex.tv/auth sofort auf watch.plex.tv weitergeschickt — OHNE dass der Code verknüpft wird. Man sieht sich angemeldet, die App wartet vergeblich, und es gibt keinerlei Hinweis. Jetzt wird plex.tv/link mit eingetragenem Code geöffnet. Diese Seite tut nur eine Sache: den Code bestätigen. Die Gerätekennung steckt ohnehin schon im PIN. Dazu ein Ausweg: bleibt es nach fünfzehn Sekunden ohne Token, erscheint der Code im Anmeldebildschirm zum Abtippen. Schließt man das Fenster ohne Erfolg, bleibt er stehen statt abzumelden — er gilt weiter. Download fragte nicht nach der Qualität Jetzt Original, Full HD, HD oder Sparsam, jeweils mit geschätzter Größe. Die Größe ist das eigentliche Entscheidungskriterium — „Full HD" allein sagt nicht, ob es aufs Telefon passt. Die komprimierten Stufen laufen über den Universal-Transcoder und sind weiterhin NICHT am Server geprüft; steht so im Quelltext. Schwarzes Bild ohne Anzeige Der Player kannte nur „spielt" und „spielt nicht". Wartete er auf Daten, stand man vor einem Standbild ohne Hinweis. Es gibt jetzt einen eigenen Pufferzustand mit Anzeige. Aufmacher war beschnitten Er stand auf 3:4, Plex liefert als `art` aber 16:9 — mit scaledToFill ging links und rechts je ein Viertel verloren, ohne dass man es dem Bild ansah. Die Seitenverhältnisse liegen jetzt an einer Stelle in FlixyAspectRatio. Schwarze Leiste über dem Aufmacher .ultraThinMaterial statt undurchsichtigem Grund: das Bild läuft unscharf hinter der Leiste weiter, statt an einer harten Kante zu enden. Weichgezeichnet, nicht abgedunkelt. 139 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -972,6 +972,74 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"downloads.refused.noMedia": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"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": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"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.noServer": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Keine Verbindung zum Server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No connection to the server."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads.refused.noURL": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloads.refused.space %@ %@": {
|
"downloads.refused.space %@ %@": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -1905,6 +1973,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.quality": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Qualität"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Quality"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.relayWarning": {
|
"player.relayWarning": {
|
||||||
"comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft",
|
"comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft",
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
@@ -2025,6 +2109,57 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quality.automatic": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Automatisch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Automatic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality.fullHD": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Full HD · 1080p"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Full HD · 1080p"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality.hd": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "HD · 720p"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "HD · 720p"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"quality.high": {
|
"quality.high": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -2059,6 +2194,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quality.lowRes": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sparsam · 480p"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Data saver · 480p"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"quality.medium": {
|
"quality.medium": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -3432,146 +3584,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloads.refused.noServer": {
|
"signIn.manualHint": {
|
||||||
|
"comment": "Hinweis, wenn die Browser-Verknüpfung nicht greift",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Keine Verbindung zum Server."
|
"value": "Hat das Fenster nicht verknüpft? Öffne plex.tv/link und gib diesen Code ein:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "No connection to the server."
|
"value": "Window didn't link? Open plex.tv/link and enter this code:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloads.refused.noMedia": {
|
"downloads.quality.title": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Zu diesem Titel liegt keine Datei auf dem Server."
|
"value": "In welcher Qualität?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "There's no file for this title on the server."
|
"value": "Which quality?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloads.refused.noPart": {
|
"downloads.quality.hint": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Die Datei auf dem Server hat keinen abrufbaren Teil. Möglicherweise wurde sie noch nicht analysiert."
|
"value": "Niedrigere Stufen rechnet der Server unterwegs herunter. Das dauert länger, spart aber deutlich Platz."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "The file on the server has no retrievable part. It may not have been analyzed yet."
|
"value": "Lower levels are transcoded by the server. That takes longer but saves a lot of space."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloads.refused.noURL": {
|
"downloads.quality.approx %@ %@": {
|
||||||
|
"comment": "Platzhalter: Stufenname, geschätzte Größe",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Die Download-Adresse ließ sich nicht bilden."
|
"value": "%@ · etwa %@"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "The download address couldn't be built."
|
"value": "%@ · about %@"
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,50 @@ import PlexKit
|
|||||||
/// Die Trennung ist Absicht: `DownloadCoordinator` kennt nur Adressen, Bytes
|
/// Die Trennung ist Absicht: `DownloadCoordinator` kennt nur Adressen, Bytes
|
||||||
/// und Zustände — nichts von Plex. Dadurch bleibt die Engine ohne Server
|
/// und Zustände — nichts von Plex. Dadurch bleibt die Engine ohne Server
|
||||||
/// testbar, und dieser Typ ist die einzige Stelle, die beides kennt.
|
/// testbar, und dieser Typ ist die einzige Stelle, die beides kennt.
|
||||||
|
/// In welcher Qualität heruntergeladen wird.
|
||||||
|
enum DownloadQuality: String, CaseIterable, Identifiable {
|
||||||
|
/// Die Datei, wie sie auf dem Server liegt.
|
||||||
|
case original
|
||||||
|
case fullHD
|
||||||
|
case hd
|
||||||
|
case low
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var maximumBitrate: Int? {
|
||||||
|
switch self {
|
||||||
|
case .original: nil
|
||||||
|
case .fullHD: 8_000
|
||||||
|
case .hd: 4_000
|
||||||
|
case .low: 2_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolution: String? {
|
||||||
|
switch self {
|
||||||
|
case .original: nil
|
||||||
|
case .fullHD: "1920x1080"
|
||||||
|
case .hd: "1280x720"
|
||||||
|
case .low: "854x480"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grobe Schätzung der Dateigröße bei dieser Stufe.
|
||||||
|
///
|
||||||
|
/// Aus Bitrate mal Laufzeit. Nicht genau — der Transcoder arbeitet mit
|
||||||
|
/// variabler Bitrate —, aber genau genug für die Frage, ob es aufs Gerät
|
||||||
|
/// passt. Bei `original` steht die echte Größe zur Verfügung.
|
||||||
|
func estimatedBytes(durationMilliseconds: Int?, originalBytes: Int64) -> Int64 {
|
||||||
|
guard let maximumBitrate, let durationMilliseconds, durationMilliseconds > 0 else {
|
||||||
|
return originalBytes
|
||||||
|
}
|
||||||
|
let seconds = Double(durationMilliseconds) / 1000
|
||||||
|
let estimate = Int64(Double(maximumBitrate) * 1000 / 8 * seconds)
|
||||||
|
// Nie mehr schätzen als das Original groß ist.
|
||||||
|
return originalBytes > 0 ? min(estimate, originalBytes) : estimate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct DownloadService {
|
struct DownloadService {
|
||||||
let server: PlexServer?
|
let server: PlexServer?
|
||||||
@@ -18,7 +62,11 @@ struct DownloadService {
|
|||||||
/// ist nicht akademisch: mit `nil` sah der Download-Knopf aus, als täte er
|
/// ist nicht akademisch: mit `nil` sah der Download-Knopf aus, als täte er
|
||||||
/// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server
|
/// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server
|
||||||
/// noch nicht analysiert hat.
|
/// noch nicht analysiert hat.
|
||||||
func plan(for item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadPlan {
|
func plan(
|
||||||
|
for item: PlexItem,
|
||||||
|
mediaIndex: Int = 0,
|
||||||
|
quality: DownloadQuality = .original
|
||||||
|
) throws(DownloadRefusal) -> DownloadPlan {
|
||||||
guard let server else {
|
guard let server else {
|
||||||
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
|
||||||
}
|
}
|
||||||
@@ -31,7 +79,22 @@ struct DownloadService {
|
|||||||
guard let part = media.parts.first else {
|
guard let part = media.parts.first else {
|
||||||
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
|
||||||
}
|
}
|
||||||
guard let url = server.downloadURL(part: part) else {
|
let url: URL?
|
||||||
|
switch quality {
|
||||||
|
case .original:
|
||||||
|
url = server.downloadURL(part: part)
|
||||||
|
case .fullHD, .hd, .low:
|
||||||
|
// Über den Universal-Transcoder. NOCH NICHT AM SERVER GEPRÜFT:
|
||||||
|
// offen ist, ob Plex eine Content-Length mitschickt — ohne sie
|
||||||
|
// gibt es keinen Fortschrittsbalken und kein Fortsetzen.
|
||||||
|
url = server.transcodedDownloadURL(
|
||||||
|
ratingKey: item.ratingKey,
|
||||||
|
mediaIndex: mediaIndex,
|
||||||
|
maximumBitrate: quality.maximumBitrate ?? 8_000,
|
||||||
|
resolution: quality.resolution
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let url else {
|
||||||
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noURL"))
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noURL"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +109,22 @@ struct DownloadService {
|
|||||||
seasonIndex: item.parentIndex,
|
seasonIndex: item.parentIndex,
|
||||||
episodeIndex: item.index,
|
episodeIndex: item.index,
|
||||||
durationMilliseconds: item.duration,
|
durationMilliseconds: item.duration,
|
||||||
expectedBytes: part.size ?? 0,
|
expectedBytes: quality.estimatedBytes(
|
||||||
|
durationMilliseconds: item.duration,
|
||||||
|
originalBytes: part.size ?? 0
|
||||||
|
),
|
||||||
offlineMedia: offlineMedia(for: item, media: media, part: part)
|
offlineMedia: offlineMedia(for: item, media: media, part: part)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt einen einzelnen Titel.
|
/// Lädt einen einzelnen Titel.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func download(_ item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadItem {
|
func download(
|
||||||
try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex))
|
_ item: PlexItem,
|
||||||
|
mediaIndex: Int = 0,
|
||||||
|
quality: DownloadQuality = .original
|
||||||
|
) throws(DownloadRefusal) -> DownloadItem {
|
||||||
|
try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex, quality: quality))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt alle Folgen einer Staffel oder Serie.
|
/// Lädt alle Folgen einer Staffel oder Serie.
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ final class FlixySession {
|
|||||||
/// bestätigten Code darüber diesem Gerät zu.
|
/// bestätigten Code darüber diesem Gerät zu.
|
||||||
var clientIdentifier: String { identity.clientIdentifier }
|
var clientIdentifier: String { identity.clientIdentifier }
|
||||||
|
|
||||||
|
/// Der Code der laufenden Anmeldung.
|
||||||
|
///
|
||||||
|
/// Wird nach kurzer Zeit im Anmeldebildschirm eingeblendet — als Ausweg,
|
||||||
|
/// falls die Browser-Seite den Code nicht verknüpft. Das passiert, wenn
|
||||||
|
/// man dort schon angemeldet ist, und ohne diesen Ausweg säße man fest.
|
||||||
|
private(set) var pendingCode: String?
|
||||||
|
private(set) var showsManualCode = false
|
||||||
|
|
||||||
private(set) var phase: Phase = .launching
|
private(set) var phase: Phase = .launching
|
||||||
private(set) var server: PlexServer?
|
private(set) var server: PlexServer?
|
||||||
/// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos
|
/// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos
|
||||||
@@ -169,23 +177,27 @@ final class FlixySession {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let url = pin.authorizationURL(
|
guard let url = pin.linkURL else {
|
||||||
clientIdentifier: identity.clientIdentifier,
|
|
||||||
product: Branding.plexProduct,
|
|
||||||
forwardURL: Branding.authCallbackURL
|
|
||||||
) else {
|
|
||||||
phase = .failed(message: String(localized: "signIn.windowFailed"))
|
phase = .failed(message: String(localized: "signIn.windowFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingCode = pin.code
|
||||||
|
showsManualCode = false
|
||||||
webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme)
|
webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme)
|
||||||
|
|
||||||
// Die Frist entspricht der Gültigkeit des Codes bei Plex.
|
// Die Frist entspricht der Gültigkeit des Codes bei Plex.
|
||||||
for _ in 0..<900 {
|
for second in 0..<900 {
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
|
||||||
|
// Nach einer Viertelminute den Code zeigen. Wer bis dahin nicht
|
||||||
|
// durch ist, hat vermutlich den Fall erwischt, in dem die Seite
|
||||||
|
// nicht verknüpft — dann hilft nur noch das Abtippen.
|
||||||
|
if second == 15 { showsManualCode = true }
|
||||||
|
|
||||||
if let token = try? await authenticator.token(for: pin) {
|
if let token = try? await authenticator.token(for: pin) {
|
||||||
webAuthenticator.dismiss()
|
webAuthenticator.dismiss()
|
||||||
|
clearPendingCode()
|
||||||
await connect(accountToken: token)
|
await connect(accountToken: token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -199,10 +211,14 @@ final class FlixySession {
|
|||||||
// bevor aufgegeben wird — zwischen Rücksprung und Eintrag
|
// bevor aufgegeben wird — zwischen Rücksprung und Eintrag
|
||||||
// bei Plex liegt manchmal eine Sekunde.
|
// bei Plex liegt manchmal eine Sekunde.
|
||||||
if let token = try? await graceTokenLookup(pin: pin) {
|
if let token = try? await graceTokenLookup(pin: pin) {
|
||||||
|
clearPendingCode()
|
||||||
await connect(accountToken: token)
|
await connect(accountToken: token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
phase = .signedOut
|
// Fenster zu, kein Token: nicht abmelden, sondern den Code
|
||||||
|
// stehen lassen. Er gilt weiter, und das Abtippen bei
|
||||||
|
// plex.tv/link ist jetzt der schnellste Weg.
|
||||||
|
showsManualCode = true
|
||||||
case .presentationFailed(let description):
|
case .presentationFailed(let description):
|
||||||
phase = .failed(message: description)
|
phase = .failed(message: description)
|
||||||
}
|
}
|
||||||
@@ -211,9 +227,15 @@ final class FlixySession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
webAuthenticator.dismiss()
|
webAuthenticator.dismiss()
|
||||||
|
clearPendingCode()
|
||||||
phase = .failed(message: String(localized: "signIn.timedOut"))
|
phase = .failed(message: String(localized: "signIn.timedOut"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func clearPendingCode() {
|
||||||
|
pendingCode = nil
|
||||||
|
showsManualCode = false
|
||||||
|
}
|
||||||
|
|
||||||
/// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach.
|
/// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach.
|
||||||
private func graceTokenLookup(pin: PlexPin) async throws -> String? {
|
private func graceTokenLookup(pin: PlexPin) async throws -> String? {
|
||||||
for _ in 0..<5 {
|
for _ in 0..<5 {
|
||||||
@@ -226,6 +248,8 @@ final class FlixySession {
|
|||||||
func cancelSignIn() {
|
func cancelSignIn() {
|
||||||
authorizationTask?.cancel()
|
authorizationTask?.cancel()
|
||||||
authorizationTask = nil
|
authorizationTask = nil
|
||||||
|
webAuthenticator.dismiss()
|
||||||
|
clearPendingCode()
|
||||||
phase = .signedOut
|
phase = .signedOut
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ struct DownloadButton: View {
|
|||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
@State private var refusal: DownloadRefusal?
|
@State private var refusal: DownloadRefusal?
|
||||||
|
@State private var showsQualityChoice = false
|
||||||
|
|
||||||
private var existing: DownloadItem? {
|
private var existing: DownloadItem? {
|
||||||
downloads.items.first { $0.ratingKey == item.ratingKey }
|
downloads.items.first { $0.ratingKey == item.ratingKey }
|
||||||
@@ -34,6 +35,18 @@ struct DownloadButton: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
|
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
|
||||||
.accessibilityLabel(Text(label))
|
.accessibilityLabel(Text(label))
|
||||||
|
.confirmationDialog(
|
||||||
|
Text("downloads.quality.title"),
|
||||||
|
isPresented: $showsQualityChoice,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
ForEach(DownloadQuality.allCases) { quality in
|
||||||
|
Button(qualityLabel(quality)) { enqueue(quality: quality) }
|
||||||
|
}
|
||||||
|
Button("action.cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("downloads.quality.hint")
|
||||||
|
}
|
||||||
.alert(
|
.alert(
|
||||||
Text("downloads.refused.title"),
|
Text("downloads.refused.title"),
|
||||||
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
|
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
|
||||||
@@ -66,7 +79,10 @@ struct DownloadButton: View {
|
|||||||
|
|
||||||
private func act() {
|
private func act() {
|
||||||
guard let existing else {
|
guard let existing else {
|
||||||
enqueue()
|
// Vor dem Laden fragen. Der Unterschied zwischen einem
|
||||||
|
// 40-GB-Remux und 2 GB in 720p ist auf einem Telefon keine
|
||||||
|
// Nebensache — und hinterher lässt er sich nicht mehr ändern.
|
||||||
|
showsQualityChoice = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,14 +99,48 @@ struct DownloadButton: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enqueue() {
|
private func enqueue(quality: DownloadQuality) {
|
||||||
let service = DownloadService(server: session.server, coordinator: downloads)
|
let service = DownloadService(server: session.server, coordinator: downloads)
|
||||||
do {
|
do {
|
||||||
_ = try service.download(item, mediaIndex: mediaIndex)
|
_ = try service.download(item, mediaIndex: mediaIndex, quality: quality)
|
||||||
} catch {
|
} catch {
|
||||||
refusal = error
|
refusal = error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Beschriftung samt geschätzter Größe.
|
||||||
|
///
|
||||||
|
/// Die Größe ist das eigentliche Entscheidungskriterium — „Full HD" allein
|
||||||
|
/// sagt nichts darüber, ob es aufs Gerät passt.
|
||||||
|
private func qualityLabel(_ quality: DownloadQuality) -> String {
|
||||||
|
let media = item.media.indices.contains(mediaIndex)
|
||||||
|
? item.media[mediaIndex]
|
||||||
|
: item.preferredMedia
|
||||||
|
let originalBytes = media?.parts.first?.size ?? 0
|
||||||
|
let bytes = quality.estimatedBytes(
|
||||||
|
durationMilliseconds: item.duration,
|
||||||
|
originalBytes: originalBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
let name = String(localized: quality.localizedName)
|
||||||
|
guard bytes > 0 else { return name }
|
||||||
|
|
||||||
|
let size = bytes.formatted(.byteCount(style: .file))
|
||||||
|
return quality == .original
|
||||||
|
? "\(name) · \(size)"
|
||||||
|
: String(localized: "downloads.quality.approx \(name) \(size)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DownloadQuality {
|
||||||
|
var localizedName: String.LocalizationValue {
|
||||||
|
switch self {
|
||||||
|
case .original: "quality.original"
|
||||||
|
case .fullHD: "quality.fullHD"
|
||||||
|
case .hd: "quality.hd"
|
||||||
|
case .low: "quality.lowRes"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Knopf für eine ganze Staffel oder Serie.
|
/// Der Knopf für eine ganze Staffel oder Serie.
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ struct HomeView: View {
|
|||||||
.background(Color.flixyCanvas)
|
.background(Color.flixyCanvas)
|
||||||
.navigationTitle(Branding.appName)
|
.navigationTitle(Branding.appName)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
// Unscharf statt schwarz: der Aufmacher läuft hinter der Leiste
|
||||||
|
// weiter, statt an einer harten Kante zu enden. `.ultraThinMaterial`
|
||||||
|
// macht genau das — es weichzeichnet, was darunter liegt, ohne es
|
||||||
|
// zusätzlich abzudunkeln.
|
||||||
|
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
||||||
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
|
// Helle Symbole und Schrift, weil darunter ein Bild liegt und kein
|
||||||
|
// heller Grund.
|
||||||
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
.toolbar { SearchToolbarItem() }
|
.toolbar { SearchToolbarItem() }
|
||||||
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
||||||
.task(id: session.server?.name) { await load() }
|
.task(id: session.server?.name) { await load() }
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ struct RootView: View {
|
|||||||
case .signedOut, .authenticating:
|
case .signedOut, .authenticating:
|
||||||
SignInView(
|
SignInView(
|
||||||
phase: session.phase,
|
phase: session.phase,
|
||||||
onSignIn: { Task { await session.beginSignIn() } }
|
manualCode: session.showsManualCode ? session.pendingCode : nil,
|
||||||
|
onSignIn: { Task { await session.beginSignIn() } },
|
||||||
|
onCancel: { session.cancelSignIn() }
|
||||||
)
|
)
|
||||||
|
|
||||||
case .choosingServer(let servers):
|
case .choosingServer(let servers):
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ final class PlayerModel {
|
|||||||
private(set) var currentTime: Duration = .zero
|
private(set) var currentTime: Duration = .zero
|
||||||
private(set) var duration: Duration?
|
private(set) var duration: Duration?
|
||||||
private(set) var isPlaying = false
|
private(set) var isPlaying = false
|
||||||
|
/// Läuft eigentlich, wartet aber auf Daten.
|
||||||
|
///
|
||||||
|
/// Ohne diese Unterscheidung stand man vor einem schwarzen Bild ohne
|
||||||
|
/// jeden Hinweis: der Player galt als „spielt", nur kam noch nichts an.
|
||||||
|
private(set) var isBuffering = false
|
||||||
|
|
||||||
private(set) var audioTracks: [MediaTrack] = []
|
private(set) var audioTracks: [MediaTrack] = []
|
||||||
private(set) var subtitleTracks: [MediaTrack] = []
|
private(set) var subtitleTracks: [MediaTrack] = []
|
||||||
@@ -447,6 +452,7 @@ final class PlayerModel {
|
|||||||
switch event {
|
switch event {
|
||||||
case .stateChanged(let state):
|
case .stateChanged(let state):
|
||||||
isPlaying = state == .playing
|
isPlaying = state == .playing
|
||||||
|
isBuffering = state == .buffering || state == .loading
|
||||||
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
|
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
|
||||||
// abzulesen, ob der Motor hängt oder die Leitung.
|
// abzulesen, ob der Motor hängt oder die Leitung.
|
||||||
diagnostics.note("Zustand: \(state)")
|
diagnostics.note("Zustand: \(state)")
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ struct PlayerView: View {
|
|||||||
failure(message: message)
|
failure(message: message)
|
||||||
|
|
||||||
case .playing:
|
case .playing:
|
||||||
|
// Wartet auf Daten: ohne Anzeige sieht das aus wie ein
|
||||||
|
// Standbild, und man weiß nicht, ob noch etwas kommt.
|
||||||
|
if model.isBuffering {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
.scaleEffect(1.4)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
if showsControls {
|
if showsControls {
|
||||||
controls(for: model).transition(.opacity)
|
controls(for: model).transition(.opacity)
|
||||||
}
|
}
|
||||||
@@ -63,6 +72,7 @@ struct PlayerView: View {
|
|||||||
// Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind.
|
// Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind.
|
||||||
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
|
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
|
||||||
// und im Querformat erst recht nicht.
|
// und im Querformat erst recht nicht.
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: model?.isBuffering)
|
||||||
.statusBarHidden(true)
|
.statusBarHidden(true)
|
||||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||||
.landscapeWhileVisible()
|
.landscapeWhileVisible()
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import PlexKit
|
|||||||
/// erreicht Flixy nie. Nach dem Anmelden schließt sich das Fenster von selbst.
|
/// erreicht Flixy nie. Nach dem Anmelden schließt sich das Fenster von selbst.
|
||||||
struct SignInView: View {
|
struct SignInView: View {
|
||||||
let phase: FlixySession.Phase
|
let phase: FlixySession.Phase
|
||||||
|
/// Der Code der laufenden Anmeldung, sobald er gezeigt werden soll.
|
||||||
|
let manualCode: String?
|
||||||
let onSignIn: () -> Void
|
let onSignIn: () -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
@@ -55,11 +58,15 @@ struct SignInView: View {
|
|||||||
.tint(accent)
|
.tint(accent)
|
||||||
.disabled(isAuthenticating)
|
.disabled(isAuthenticating)
|
||||||
|
|
||||||
|
if let manualCode {
|
||||||
|
manualFallback(code: manualCode)
|
||||||
|
} else {
|
||||||
Text("signIn.privacy")
|
Text("signIn.privacy")
|
||||||
.font(.flixyCaption)
|
.font(.flixyCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.flixyTextMuted)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer().frame(height: FlixySpacing.section)
|
Spacer().frame(height: FlixySpacing.section)
|
||||||
}
|
}
|
||||||
@@ -68,4 +75,35 @@ struct SignInView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.flixyCanvas)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Ausweg, wenn die Browser-Seite den Code nicht verknüpft.
|
||||||
|
///
|
||||||
|
/// Das passiert, wenn man dort schon bei Plex angemeldet ist: die Seite
|
||||||
|
/// springt weiter, ohne zu verknüpfen, und die App wartete vergeblich.
|
||||||
|
/// Der Code gilt weiter — abtippen führt immer zum Ziel.
|
||||||
|
@ViewBuilder
|
||||||
|
private func manualFallback(code: String) -> some View {
|
||||||
|
VStack(spacing: FlixySpacing.regular) {
|
||||||
|
Divider().overlay(Color.flixySeparator)
|
||||||
|
|
||||||
|
Text("signIn.manualHint")
|
||||||
|
.font(.flixyCaption)
|
||||||
|
.foregroundStyle(Color.flixyTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(code)
|
||||||
|
.font(.system(.title, design: .monospaced, weight: .semibold))
|
||||||
|
.tracking(6)
|
||||||
|
.foregroundStyle(Color.flixyTextPrimary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(.vertical, FlixySpacing.snug)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
||||||
|
|
||||||
|
Button("action.cancel", action: onCancel)
|
||||||
|
.font(.flixyCaption)
|
||||||
|
.foregroundStyle(Color.flixyTextSecondary)
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
private let metadata: String?
|
private let metadata: String?
|
||||||
private let summary: String?
|
private let summary: String?
|
||||||
private let artworkURL: URL?
|
private let artworkURL: URL?
|
||||||
|
private let aspectRatio: CGFloat
|
||||||
private let actions: Actions
|
private let actions: Actions
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -17,17 +18,23 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
metadata: String? = nil,
|
metadata: String? = nil,
|
||||||
summary: String? = nil,
|
summary: String? = nil,
|
||||||
artworkURL: URL?,
|
artworkURL: URL?,
|
||||||
|
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
|
||||||
|
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
|
||||||
|
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
|
||||||
|
// sehen.
|
||||||
|
aspectRatio: CGFloat = FlixyAspectRatio.hero,
|
||||||
@ViewBuilder actions: () -> Actions
|
@ViewBuilder actions: () -> Actions
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.metadata = metadata
|
self.metadata = metadata
|
||||||
self.summary = summary
|
self.summary = summary
|
||||||
self.artworkURL = artworkURL
|
self.artworkURL = artworkURL
|
||||||
|
self.aspectRatio = aspectRatio
|
||||||
self.actions = actions()
|
self.actions = actions()
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
FlixyArtwork(url: artworkURL, aspectRatio: 3.0 / 4.0)
|
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio)
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
|
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
|
||||||
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
|
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
|
||||||
|
|||||||
@@ -39,3 +39,19 @@ public enum FlixyRadius {
|
|||||||
public static let sheet: CGFloat = 16
|
public static let sheet: CGFloat = 16
|
||||||
public static let control: CGFloat = 10
|
public static let control: CGFloat = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Seitenverhältnisse, in denen Plex seine Bilder liefert.
|
||||||
|
///
|
||||||
|
/// An einer Stelle, weil ein falscher Wert nicht auffällt, sondern nur
|
||||||
|
/// beschneidet: ein 16:9-Hintergrundbild in einem 3:4-Rahmen verliert links
|
||||||
|
/// und rechts je ein Viertel, und man sieht dem Ergebnis nicht an, dass etwas
|
||||||
|
/// fehlt.
|
||||||
|
public enum FlixyAspectRatio {
|
||||||
|
/// Kinoplakate im Hochformat.
|
||||||
|
public static let poster: CGFloat = 2.0 / 3.0
|
||||||
|
/// Szenenbilder und Hintergrundbilder.
|
||||||
|
public static let landscape: CGFloat = 16.0 / 9.0
|
||||||
|
/// Der Aufmacher auf der Startseite — dasselbe wie `landscape`, weil Plex
|
||||||
|
/// dafür `art` liefert.
|
||||||
|
public static let hero: CGFloat = landscape
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ public struct PlexPin: Sendable, Equatable, Decodable {
|
|||||||
self.expiresAt = expiresAt
|
self.expiresAt = expiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Verknüpfungsseite von Plex, mit bereits eingetragenem Code.
|
||||||
|
///
|
||||||
|
/// Gegenüber `app.plex.tv/auth` der verlässlichere Weg — und zwar genau in
|
||||||
|
/// dem Fall, der sonst stillschweigend scheitert: Wer im Browser schon bei
|
||||||
|
/// Plex angemeldet ist, wird von der Auth-Seite sofort auf watch.plex.tv
|
||||||
|
/// weitergeschickt, **ohne dass der Code verknüpft wird**. Man sieht sich
|
||||||
|
/// angemeldet, die App wartet vergeblich.
|
||||||
|
///
|
||||||
|
/// Diese Seite tut nur eine Sache: den Code bestätigen. Die Kennung des
|
||||||
|
/// Geräts steckt bereits im PIN — sie kam beim Anlegen über die
|
||||||
|
/// `X-Plex-*`-Kopfzeilen mit.
|
||||||
|
public var linkURL: URL? {
|
||||||
|
let allowed = CharacterSet.alphanumerics
|
||||||
|
guard let code = code.addingPercentEncoding(withAllowedCharacters: allowed) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return URL(string: "https://plex.tv/link/?pin=\(code)")
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Adresse, unter der der Nutzer den Code bestätigt.
|
/// Die Adresse, unter der der Nutzer den Code bestätigt.
|
||||||
///
|
///
|
||||||
/// Von Hand zusammengesetzt statt über `URLComponents`. Der Grund ist die
|
/// Von Hand zusammengesetzt statt über `URLComponents`. Der Grund ist die
|
||||||
|
|||||||
@@ -79,3 +79,23 @@ struct AuthorizationForwardURLTests {
|
|||||||
#expect(text.contains("context%5Bdevice%5D%5Bproduct%5D=Flixy"))
|
#expect(text.contains("context%5Bdevice%5D%5Bproduct%5D=Flixy"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite("Verknüpfungsseite")
|
||||||
|
struct LinkURLTests {
|
||||||
|
@Test("Der Code steht in der Adresse")
|
||||||
|
func carriesTheCode() throws {
|
||||||
|
let pin = PlexPin(id: 1, code: "S6J9")
|
||||||
|
let url = try #require(pin.linkURL)
|
||||||
|
#expect(url.absoluteString == "https://plex.tv/link/?pin=S6J9")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Sonderzeichen im Code werden kodiert")
|
||||||
|
func encodesUnusualCodes() throws {
|
||||||
|
// Kurze Codes sind alphanumerisch, aber darauf verlassen möchte ich
|
||||||
|
// mich nicht — ein ungeschütztes `&` würde die Adresse zerreißen.
|
||||||
|
let pin = PlexPin(id: 1, code: "A&B=C")
|
||||||
|
let url = try #require(pin.linkURL)
|
||||||
|
#expect(!url.absoluteString.contains("&B"))
|
||||||
|
#expect(url.absoluteString.contains("A%26B%3DC"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user