diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index c49f8c8..dab0f56 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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 %@ %@": { "extractionState": "stale", "localizations": { @@ -1905,6 +1973,22 @@ } } }, + "player.quality": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Qualität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Quality" + } + } + } + }, "player.relayWarning": { "comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft", "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": { "extractionState": "stale", "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": { "extractionState": "stale", "localizations": { @@ -3432,146 +3584,68 @@ } } }, - "downloads.refused.noServer": { + "signIn.manualHint": { + "comment": "Hinweis, wenn die Browser-Verknüpfung nicht greift", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Keine Verbindung zum Server." + "value": "Hat das Fenster nicht verknüpft? Öffne plex.tv/link und gib diesen Code ein:" } }, "en": { "stringUnit": { "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": { "de": { "stringUnit": { "state": "translated", - "value": "Zu diesem Titel liegt keine Datei auf dem Server." + "value": "In welcher Qualität?" } }, "en": { "stringUnit": { "state": "translated", - "value": "There's no file for this title on the server." + "value": "Which quality?" } } } }, - "downloads.refused.noPart": { + "downloads.quality.hint": { "localizations": { "de": { "stringUnit": { "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": { "stringUnit": { "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": { "de": { "stringUnit": { "state": "translated", - "value": "Die Download-Adresse ließ sich nicht bilden." + "value": "%@ · etwa %@" } }, "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" + "value": "%@ · about %@" } } } diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift index fadefd2..47fcf56 100644 --- a/Flixy/Session/DownloadService.swift +++ b/Flixy/Session/DownloadService.swift @@ -7,6 +7,50 @@ import PlexKit /// Die Trennung ist Absicht: `DownloadCoordinator` kennt nur Adressen, Bytes /// und Zustände — nichts von Plex. Dadurch bleibt die Engine ohne Server /// 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 struct DownloadService { let server: PlexServer? @@ -18,7 +62,11 @@ struct DownloadService { /// 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 /// 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 { throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer")) } @@ -31,7 +79,22 @@ struct DownloadService { guard let part = media.parts.first else { 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")) } @@ -46,15 +109,22 @@ struct DownloadService { seasonIndex: item.parentIndex, episodeIndex: item.index, 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) ) } /// Lädt einen einzelnen Titel. @discardableResult - func download(_ item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadItem { - try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex)) + func download( + _ 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. diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift index 616eddf..98fd3e0 100644 --- a/Flixy/Session/FlixySession.swift +++ b/Flixy/Session/FlixySession.swift @@ -59,6 +59,14 @@ final class FlixySession { /// bestätigten Code darüber diesem Gerät zu. 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 server: PlexServer? /// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos @@ -169,23 +177,27 @@ final class FlixySession { return } - guard let url = pin.authorizationURL( - clientIdentifier: identity.clientIdentifier, - product: Branding.plexProduct, - forwardURL: Branding.authCallbackURL - ) else { + guard let url = pin.linkURL else { phase = .failed(message: String(localized: "signIn.windowFailed")) return } + pendingCode = pin.code + showsManualCode = false webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme) // 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)) + // 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) { webAuthenticator.dismiss() + clearPendingCode() await connect(accountToken: token) return } @@ -199,10 +211,14 @@ final class FlixySession { // bevor aufgegeben wird — zwischen Rücksprung und Eintrag // bei Plex liegt manchmal eine Sekunde. if let token = try? await graceTokenLookup(pin: pin) { + clearPendingCode() await connect(accountToken: token) 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): phase = .failed(message: description) } @@ -211,9 +227,15 @@ final class FlixySession { } webAuthenticator.dismiss() + clearPendingCode() 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. private func graceTokenLookup(pin: PlexPin) async throws -> String? { for _ in 0..<5 { @@ -226,6 +248,8 @@ final class FlixySession { func cancelSignIn() { authorizationTask?.cancel() authorizationTask = nil + webAuthenticator.dismiss() + clearPendingCode() phase = .signedOut } diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift index 47c5fcf..2d17bdf 100644 --- a/Flixy/Views/Downloads/DownloadButton.swift +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -17,6 +17,7 @@ struct DownloadButton: View { @Environment(\.flixyAccent) private var accent @State private var refusal: DownloadRefusal? + @State private var showsQualityChoice = false private var existing: DownloadItem? { downloads.items.first { $0.ratingKey == item.ratingKey } @@ -34,6 +35,18 @@ struct DownloadButton: View { .buttonStyle(.bordered) .tint(existing?.state == .completed ? accent : Color.flixyTextSecondary) .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( Text("downloads.refused.title"), isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } }) @@ -66,7 +79,10 @@ struct DownloadButton: View { private func act() { 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 } @@ -83,14 +99,48 @@ struct DownloadButton: View { } } - private func enqueue() { + private func enqueue(quality: DownloadQuality) { let service = DownloadService(server: session.server, coordinator: downloads) do { - _ = try service.download(item, mediaIndex: mediaIndex) + _ = try service.download(item, mediaIndex: mediaIndex, quality: quality) } catch { 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. diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 0be4314..3b98c64 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -39,6 +39,15 @@ struct HomeView: View { .background(Color.flixyCanvas) .navigationTitle(Branding.appName) .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() } .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } .task(id: session.server?.name) { await load() } diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index f113785..03a784c 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -27,7 +27,9 @@ struct RootView: View { case .signedOut, .authenticating: SignInView( 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): diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 7986796..ba52d9e 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -29,6 +29,11 @@ final class PlayerModel { private(set) var currentTime: Duration = .zero private(set) var duration: Duration? 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 subtitleTracks: [MediaTrack] = [] @@ -447,6 +452,7 @@ final class PlayerModel { switch event { case .stateChanged(let state): isPlaying = state == .playing + isBuffering = state == .buffering || state == .loading // Jeder Zustandswechsel mit Zeitstempel. Erst daran ist // abzulesen, ob der Motor hängt oder die Leitung. diagnostics.note("Zustand: \(state)") diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index b987fbe..02582a5 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -38,6 +38,15 @@ struct PlayerView: View { failure(message: message) 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 { controls(for: model).transition(.opacity) } @@ -63,6 +72,7 @@ struct PlayerView: View { // Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind. // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen — // und im Querformat erst recht nicht. + .animation(.easeInOut(duration: 0.2), value: model?.isBuffering) .statusBarHidden(true) .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible() diff --git a/Flixy/Views/SignIn/SignInView.swift b/Flixy/Views/SignIn/SignInView.swift index 4a42afc..84744b2 100644 --- a/Flixy/Views/SignIn/SignInView.swift +++ b/Flixy/Views/SignIn/SignInView.swift @@ -9,7 +9,10 @@ import PlexKit /// erreicht Flixy nie. Nach dem Anmelden schließt sich das Fenster von selbst. struct SignInView: View { let phase: FlixySession.Phase + /// Der Code der laufenden Anmeldung, sobald er gezeigt werden soll. + let manualCode: String? let onSignIn: () -> Void + let onCancel: () -> Void @Environment(\.flixyAccent) private var accent @@ -55,10 +58,14 @@ struct SignInView: View { .tint(accent) .disabled(isAuthenticating) - Text("signIn.privacy") - .font(.flixyCaption) - .foregroundStyle(Color.flixyTextMuted) - .multilineTextAlignment(.center) + if let manualCode { + manualFallback(code: manualCode) + } else { + Text("signIn.privacy") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .multilineTextAlignment(.center) + } } Spacer().frame(height: FlixySpacing.section) @@ -68,4 +75,35 @@ struct SignInView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .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) + } } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index b383df2..58af1f4 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -10,6 +10,7 @@ public struct HeroBanner: View { private let metadata: String? private let summary: String? private let artworkURL: URL? + private let aspectRatio: CGFloat private let actions: Actions public init( @@ -17,17 +18,23 @@ public struct HeroBanner: View { metadata: String? = nil, summary: String? = nil, 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 ) { self.title = title self.metadata = metadata self.summary = summary self.artworkURL = artworkURL + self.aspectRatio = aspectRatio self.actions = actions() } public var body: some View { - FlixyArtwork(url: artworkURL, aspectRatio: 3.0 / 4.0) + FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio) .overlay(alignment: .bottom) { // Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in // Schwarz: sonst entsteht genau dort, wo der Aufmacher in die diff --git a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift index d43ceb0..f52539c 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift @@ -39,3 +39,19 @@ public enum FlixyRadius { public static let sheet: CGFloat = 16 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 +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Auth/PlexPin.swift b/Packages/FlixyKit/Sources/PlexKit/Auth/PlexPin.swift index 7e6cf9e..668e674 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Auth/PlexPin.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Auth/PlexPin.swift @@ -39,6 +39,25 @@ public struct PlexPin: Sendable, Equatable, Decodable { 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. /// /// Von Hand zusammengesetzt statt über `URLComponents`. Der Grund ist die diff --git a/Packages/FlixyKit/Tests/PlexKitTests/AuthorizationURLTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/AuthorizationURLTests.swift index e0c3e37..19bad1f 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/AuthorizationURLTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/AuthorizationURLTests.swift @@ -79,3 +79,23 @@ struct AuthorizationForwardURLTests { #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")) + } +}