From 26c909a2e2f4a5e5e0009b771a7eddf40e10e955 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 12 Sep 2026 17:09:22 +0200 Subject: [PATCH] =?UTF-8?q?Mini-Player:=20die=20Wiedergabe=20=C3=BCberlebt?= =?UTF-8?q?=20das=20Zur=C3=BCckgehen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wer im Fernsehen etwas sieht und nebenher nachsehen will, was sonst läuft, musste bisher abbrechen. Plex' eigene Handy-Apps können das auch nicht — kein Grund, es hier genauso zu machen. Der eigentliche Umbau ist nicht die Kachel, sondern der Besitz. Die Wiedergabe hing an der Sichtbarkeit der Ansicht, und zwar dreifach: das Modell war `@State` im Player, `onDisappear` hat den Motor abgeräumt, und die Bildebene entstand in `makeUIView` neu. Zurückgehen hieß deshalb: Motor tot, Transcoder aus, beim Live-TV der Tuner freigegeben. Jetzt gehört das Modell der Wurzelansicht, die immer da ist. Verkleinern und Vergrößern wechseln nur noch die Darstellung; beendet wird die Wiedergabe an genau einer Stelle, und zwar weil jemand es will. Die Bildebene hält das Modell selbst — dasselbe Muster, das VLC seit jeher verwendet. Dieselbe `UIView` wandert zwischen Kachel und Vollbild, statt neu zu entstehen; sonst setzte das Bild bei jedem Wechsel neu an. Bild-in-Bild wandert mit und bekommt endlich einen Delegaten: bisher war es eine Einbahnstraße, aus der man nicht in die App zurückkam. Die Kachel schwebt und lässt sich in jede Ecke ziehen, statt als Leiste unten zu kleben — eine Leiste nähme jeder Liste Platz weg und müsste in jedem Bildschirm bedacht werden. Die Eckenberechnung steht in MiniPlayerPlacement und ist geprüft: in einer View wäre eine Kachel, die halb aus dem Bild ragt, erst auf dem Gerät aufgefallen. Der Pfeil oben verkleinert, ein neuer Knopf daneben beendet wirklich. Co-Authored-By: Claude Opus 5 --- Flixy/Resources/Localizable.xcstrings | 5228 +++++++++-------- Flixy/Session/PlaybackCoordinator.swift | 67 +- Flixy/Views/Downloads/DownloadsView.swift | 4 +- Flixy/Views/LiveTV/LiveTVView.swift | 10 +- Flixy/Views/Navigation/RootView.swift | 69 +- Flixy/Views/Player/MiniPlayer.swift | 190 + Flixy/Views/Player/PlayerModel.swift | 77 + Flixy/Views/Player/PlayerView.swift | 73 +- Flixy/Views/Player/VideoSurface.swift | 47 +- .../FlixyDesign/MiniPlayerPlacement.swift | 84 + .../MiniPlayerPlacementTests.swift | 88 + 11 files changed, 3269 insertions(+), 2668 deletions(-) create mode 100644 Flixy/Views/Player/MiniPlayer.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift create mode 100644 Packages/FlixyKit/Tests/FlixyDesignTests/MiniPlayerPlacementTests.swift diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index fbcc794..08ca871 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -1,4012 +1,4076 @@ { - "sourceLanguage" : "en", - "strings" : { - "%@ → %@" : { - "comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ → %2$@" + "sourceLanguage": "en", + "strings": { + "%@ GB": { + "comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.", + "isCommentAutoGenerated": true + }, + "%@ → %@": { + "comment": "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.", + "isCommentAutoGenerated": true, + "localizations": { + "en": { + "stringUnit": { + "state": "new", + "value": "%1$@ → %2$@" } } } }, - "%@ GB" : { - "comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.", - "isCommentAutoGenerated" : true + "1080p": { + "comment": "A label for a 1080p video.", + "isCommentAutoGenerated": true + }, + "4K": { + "comment": "A label for a 4K video.", + "isCommentAutoGenerated": true + }, + "720p": { + "comment": "A label for a 720p video.", + "isCommentAutoGenerated": true }, - "4K" : { - "comment" : "A label for a 4K video.", - "isCommentAutoGenerated" : true + "SD": { + "comment": "A label for a 720p movie.", + "isCommentAutoGenerated": true }, - "720p" : { - "comment" : "A label for a 720p video.", - "isCommentAutoGenerated" : true + "action.apply": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Übernehmen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Apply" + } + } + } }, - "1080p" : { - "comment" : "A label for a 1080p video.", - "isCommentAutoGenerated" : true + "action.cancel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + } + } }, - "action.apply" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Übernehmen" + "action.clearRating": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bewertung entfernen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apply" + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear rating" } } } }, - "action.cancel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abbrechen" + "action.close": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancel" + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" } } } }, - "action.clearRating" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bewertung entfernen" + "action.delete": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Clear rating" + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" } } } }, - "action.close" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Schließen" + "action.markUnwatched": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Als ungesehen markieren" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close" + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark as unwatched" } } } }, - "action.delete" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Löschen" + "action.markWatched": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Als gesehen markieren" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delete" + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark as watched" } } } }, - "action.markUnwatched" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Als ungesehen markieren" + "action.rate": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bewerten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mark as unwatched" + "en": { + "stringUnit": { + "state": "translated", + "value": "Rate" } } } }, - "action.markWatched" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Als gesehen markieren" + "action.retry": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut versuchen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mark as watched" + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again" } } } }, - "action.rate" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bewerten" + "action.signOut": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abmelden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rate" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out" } } } }, - "action.retry" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erneut versuchen" + "appearance.accent": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akzentfarbe" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Try again" + "en": { + "stringUnit": { + "state": "translated", + "value": "Accent color" } } } }, - "action.signOut" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abmelden" + "appearance.accentExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Farbe erscheint auf Knöpfen, Fortschrittsbalken und der ausgewählten Zeile in der Seitenleiste." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign out" + "en": { + "stringUnit": { + "state": "translated", + "value": "The color appears on buttons, progress bars and the selected row in the sidebar." } } } }, - "appearance.accent" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akzentfarbe" + "appearance.adjusted %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Farbe wurde auf %@ aufgehellt, damit sie auf dem dunklen Hintergrund lesbar bleibt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Accent color" + "en": { + "stringUnit": { + "state": "translated", + "value": "Lightened to %@ so it stays readable on the dark background." } } } }, - "appearance.accentExplanation" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Farbe erscheint auf Knöpfen, Fortschrittsbalken und der ausgewählten Zeile in der Seitenleiste." + "appearance.customColor": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eigene Farbe" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The color appears on buttons, progress bars and the selected row in the sidebar." + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom color" } } } }, - "appearance.adjusted %@" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Farbe wurde auf %@ aufgehellt, damit sie auf dem dunklen Hintergrund lesbar bleibt." + "appearance.hexPlaceholder": { + "comment": "Beispiel für eine Hex-Farbe", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "#1DB954" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lightened to %@ so it stays readable on the dark background." + "en": { + "stringUnit": { + "state": "translated", + "value": "#1DB954" } } } }, - "appearance.customColor" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eigene Farbe" + "appearance.invalidHex": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das ist keine gültige Farbe. Erwartet wird etwas wie #1DB954." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Custom color" + "en": { + "stringUnit": { + "state": "translated", + "value": "That's not a valid color. Try something like #1DB954." } } } }, - "appearance.hexPlaceholder" : { - "comment" : "Beispiel für eine Hex-Farbe", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "#1DB954" + "appearance.preview": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "#1DB954" + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview" } } } }, - "appearance.invalidHex" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das ist keine gültige Farbe. Erwartet wird etwas wie #1DB954." + "appearance.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erscheinungsbild" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "That's not a valid color. Try something like #1DB954." + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" } } } }, - "appearance.preview" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vorschau" + "connecting.searching": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Server werden gesucht …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Preview" + "en": { + "stringUnit": { + "state": "translated", + "value": "Looking for servers…" } } } }, - "appearance.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erscheinungsbild" + "connecting.to %@": { + "comment": "Platzhalter: Servername", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinde mit %@ …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Appearance" + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting to %@…" } } } }, - "connecting.searching" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server werden gesucht …" + "detail.cast": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Besetzung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Looking for servers…" + "en": { + "stringUnit": { + "state": "translated", + "value": "Cast" } } } }, - "connecting.to %@" : { - "comment" : "Platzhalter: Servername", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbinde mit %@ …" + "detail.episodeCount %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Folgen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connecting to %@…" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld episodes" } } } }, - "detail.cast" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Besetzung" + "detail.episodes": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Folgen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cast" + "en": { + "stringUnit": { + "state": "translated", + "value": "Episodes" } } } }, - "detail.episodeCount %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld Folgen" + "detail.playFromStart": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Von vorn abspielen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld episodes" + "en": { + "stringUnit": { + "state": "translated", + "value": "Play from start" } } } }, - "detail.episodes" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Folgen" + "detail.seasons": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Staffeln" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Episodes" + "en": { + "stringUnit": { + "state": "translated", + "value": "Seasons" } } } }, - "detail.playFromStart" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Von vorn abspielen" + "detail.toSeason": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zur Staffel" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Play from start" + "en": { + "stringUnit": { + "state": "translated", + "value": "Go to season" } } } }, - "detail.seasons" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Staffeln" + "detail.toSeries": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zur Serie" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seasons" + "en": { + "stringUnit": { + "state": "translated", + "value": "Go to series" } } } }, - "detail.toSeason" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zur Staffel" + "detail.unwatchedCount %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld ungesehen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Go to season" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld unwatched" } } } }, - "detail.toSeries" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zur Serie" + "detail.versions": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fassung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Go to series" + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" } } } }, - "detail.unwatchedCount %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld ungesehen" + "diagnostics.connection": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld unwatched" + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection" } } } }, - "detail.versions" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fassung" + "diagnostics.connectionUsed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Version" + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection" } } } }, - "diagnostics.connection" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbindung" + "diagnostics.copy": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bericht kopieren" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connection" + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy report" } } } }, - "diagnostics.connectionUsed" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbindung" + "diagnostics.copyHint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Enthält keinen Token. Kannst du unbesorgt weitergeben." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connection" + "en": { + "stringUnit": { + "state": "translated", + "value": "Contains no token. Safe to share." } } } }, - "diagnostics.copy" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bericht kopieren" + "diagnostics.lastAttempt": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Letzter Abspielversuch" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copy report" + "en": { + "stringUnit": { + "state": "translated", + "value": "Last playback attempt" } } } }, - "diagnostics.copyHint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enthält keinen Token. Kannst du unbesorgt weitergeben." + "diagnostics.liveTV": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-TV-Antwort ansehen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Contains no token. Safe to share." + "en": { + "stringUnit": { + "state": "translated", + "value": "Inspect Live TV response" } } } }, - "diagnostics.lastAttempt" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Letzter Abspielversuch" + "diagnostics.liveTVHint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt den Aufbau der Serverantwort — nur Schlüssel, Anzahl und Sendernamen, keine Zugangsdaten. Hilft dabei zu finden, wo die Sender stehen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last playback attempt" + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows the structure of the server's response — keys, counts and channel names only, no credentials. Helps locate where the channels are." } } } }, - "diagnostics.liveTV" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Live-TV-Antwort ansehen" + "diagnostics.method": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weg" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inspect Live TV response" + "en": { + "stringUnit": { + "state": "translated", + "value": "Method" } } } }, - "diagnostics.liveTVHint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zeigt den Aufbau der Serverantwort — nur Schlüssel, Anzahl und Sendernamen, keine Zugangsdaten. Hilft dabei zu finden, wo die Sender stehen." + "diagnostics.noServer": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Serververbindung." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Shows the structure of the server's response — keys, counts and channel names only, no credentials. Helps locate where the channels are." + "en": { + "stringUnit": { + "state": "translated", + "value": "No server connection." } } } }, - "diagnostics.method" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Weg" + "diagnostics.nothingYet": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch nichts abgespielt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Method" + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing played yet." } } } }, - "diagnostics.noServer" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Serververbindung." + "diagnostics.relayWarning": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No server connection." + "en": { + "stringUnit": { + "state": "translated", + "value": "Via the Plex relay. Heavily throttled — large files barely start over it." } } } }, - "diagnostics.nothingYet" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Noch nichts abgespielt." + "diagnostics.source": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Quelle (ohne Token)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nothing played yet." + "en": { + "stringUnit": { + "state": "translated", + "value": "Source (token removed)" } } } }, - "diagnostics.relayWarning" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an." + "diagnostics.steps": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ablauf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Via the Plex relay. Heavily throttled — large files barely start over it." + "en": { + "stringUnit": { + "state": "translated", + "value": "Timeline" } } } }, - "diagnostics.source" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quelle (ohne Token)" + "diagnostics.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diagnose" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Source (token removed)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Diagnostics" } } } }, - "diagnostics.steps" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ablauf" + "diagnostics.title.label": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Timeline" + "en": { + "stringUnit": { + "state": "translated", + "value": "Title" } } } }, - "diagnostics.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Diagnose" + "downloads.active": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft gerade" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Diagnostics" + "en": { + "stringUnit": { + "state": "translated", + "value": "In progress" } } } }, - "diagnostics.title.label" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Titel" + "downloads.allEpisodes": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Folgen laden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Title" + "en": { + "stringUnit": { + "state": "translated", + "value": "Download all episodes" } } } }, - "downloads.active" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Läuft gerade" + "downloads.available": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Offline verfügbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "In progress" + "en": { + "stringUnit": { + "state": "translated", + "value": "Available offline" } } } }, - "downloads.allEpisodes" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alle Folgen laden" + "downloads.batch.queued %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Folgen in die Warteschlange gelegt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download all episodes" + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued %lld episodes." } } } }, - "downloads.available" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Offline verfügbar" + "downloads.batch.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Available offline" + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloads" } } } }, - "downloads.batch.queued %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld Folgen in die Warteschlange gelegt." + "downloads.batch.unavailable %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Folgen haben keine abspielbare Datei auf dem Server." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Queued %lld episodes." + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld episodes have no playable file on the server." } } } }, - "downloads.batch.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Downloads" + "downloads.cancel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Download abbrechen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Downloads" + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel download" } } } }, - "downloads.batch.unavailable %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld Folgen haben keine abspielbare Datei auf dem Server." + "downloads.download": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Herunterladen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld episodes have no playable file on the server." + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" } } } }, - "downloads.cancel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download abbrechen" + "downloads.empty.description": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lade Filme und Folgen herunter, um sie ohne Verbindung anzusehen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancel download" + "en": { + "stringUnit": { + "state": "translated", + "value": "Download movies and episodes to watch them without a connection." } } } }, - "downloads.download" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Herunterladen" + "downloads.empty.title": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nichts offline" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download" + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing offline" } } } }, - "downloads.empty.description" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lade Filme und Folgen herunter, um sie ohne Verbindung anzusehen." + "downloads.failed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download movies and episodes to watch them without a connection." + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" } } } }, - "downloads.empty.title" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nichts offline" + "downloads.hold.offline": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Verbindung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nothing offline" + "en": { + "stringUnit": { + "state": "translated", + "value": "No connection" } } } }, - "downloads.failed" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fehlgeschlagen" + "downloads.hold.offline.description": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ohne Verbindung geht nichts weiter. Angefangene Downloads laufen weiter, sobald das Gerät wieder am Netz ist." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed" + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing transfers without a connection. Started downloads continue once the device is back online." } } } }, - "downloads.hold.offline" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Verbindung" + "downloads.hold.wifi": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wartet auf WLAN" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No connection" + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for Wi-Fi" } } } }, - "downloads.hold.offline.description" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ohne Verbindung geht nichts weiter. Angefangene Downloads laufen weiter, sobald das Gerät wieder am Netz ist." + "downloads.hold.wifi.description": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "„Nur über WLAN“ ist eingeschaltet und das Gerät hängt gerade am Mobilfunk. Sobald WLAN da ist, geht es von selbst weiter." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nothing transfers without a connection. Started downloads continue once the device is back online." + "en": { + "stringUnit": { + "state": "translated", + "value": "“Wi-Fi only” is on and the device is on cellular. Transfers resume by themselves once Wi-Fi is back." } } } }, - "downloads.hold.wifi" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wartet auf WLAN" + "downloads.movies": { + "comment": "Überschrift für heruntergeladene Filme", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Filme" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Waiting for Wi-Fi" + "en": { + "stringUnit": { + "state": "translated", + "value": "Movies" } } } }, - "downloads.hold.wifi.description" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "„Nur über WLAN“ ist eingeschaltet und das Gerät hängt gerade am Mobilfunk. Sobald WLAN da ist, geht es von selbst weiter." + "downloads.pause": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anhalten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "“Wi-Fi only” is on and the device is on cellular. Transfers resume by themselves once Wi-Fi is back." + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause" } } } }, - "downloads.movies" : { - "comment" : "Überschrift für heruntergeladene Filme", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Filme" + "downloads.paused": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Angehalten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Movies" + "en": { + "stringUnit": { + "state": "translated", + "value": "Paused" } } } }, - "downloads.pause" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anhalten" + "downloads.quality.approx %@ %@": { + "comment": "Platzhalter: Stufenname, geschätzte Größe", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · etwa %@" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pause" + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · about %@" } } } }, - "downloads.paused" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Angehalten" + "downloads.quality.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Niedrigere Stufen rechnet der Server unterwegs herunter. Das dauert länger, spart aber deutlich Platz." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Paused" + "en": { + "stringUnit": { + "state": "translated", + "value": "Lower levels are transcoded by the server. That takes longer but saves a lot of space." } } } }, - "downloads.quality.approx %@ %@" : { - "comment" : "Platzhalter: Stufenname, geschätzte Größe", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · etwa %@" + "downloads.quality.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "In welcher Qualität?" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · about %@" + "en": { + "stringUnit": { + "state": "translated", + "value": "Which quality?" } } } }, - "downloads.quality.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niedrigere Stufen rechnet der Server unterwegs herunter. Das dauert länger, spart aber deutlich Platz." + "downloads.queued": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wartet" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lower levels are transcoded by the server. That takes longer but saves a lot of space." + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued" } } } }, - "downloads.quality.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "In welcher Qualität?" + "downloads.refused.exists": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Titel steht bereits in der Warteschlange." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Which quality?" + "en": { + "stringUnit": { + "state": "translated", + "value": "This title is already queued." } } } }, - "downloads.queued" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wartet" + "downloads.refused.limit %@ %@": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Speicherlimit ist erreicht: %@ von %@ belegt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Queued" + "en": { + "stringUnit": { + "state": "translated", + "value": "Storage limit reached: %@ of %@ used." } } } }, - "downloads.refused.exists" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dieser Titel steht bereits in der Warteschlange." + "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" : "This title is already queued." + "en": { + "stringUnit": { + "state": "translated", + "value": "There's no file for this title on the server." } } } }, - "downloads.refused.limit %@ %@" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Speicherlimit ist erreicht: %@ von %@ belegt." + "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" : "Storage limit reached: %@ of %@ used." + "en": { + "stringUnit": { + "state": "translated", + "value": "The file on the server has no retrievable part. It may not have been analyzed yet." } } } }, - "downloads.refused.noMedia" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zu diesem Titel liegt keine Datei auf dem Server." + "downloads.refused.noServer": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Verbindung zum Server." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "There's no file for this title on the server." + "en": { + "stringUnit": { + "state": "translated", + "value": "No connection to 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." + "downloads.refused.noURL": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Download-Adresse ließ sich nicht bilden." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The file on the server has no retrievable part. It may not have been analyzed yet." + "en": { + "stringUnit": { + "state": "translated", + "value": "The download address couldn't be built." } } } }, - "downloads.refused.noServer" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Verbindung zum Server." + "downloads.refused.space %@ %@": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu wenig Platz auf dem Gerät: %@ nötig, %@ frei." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No connection to the server." + "en": { + "stringUnit": { + "state": "translated", + "value": "Not enough space on this device: %@ needed, %@ free." } } } }, - "downloads.refused.noURL" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Download-Adresse ließ sich nicht bilden." + "downloads.refused.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Download nicht möglich" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The download address couldn't be built." + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't download" } } } }, - "downloads.refused.space %@ %@" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zu wenig Platz auf dem Gerät: %@ nötig, %@ frei." + "downloads.removeConfirm": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Download entfernen?" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Not enough space on this device: %@ needed, %@ free." + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove download?" } } } }, - "downloads.refused.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download nicht möglich" + "downloads.resume": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortsetzen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Can't download" + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume" } } } }, - "downloads.removeConfirm" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download entfernen?" + "downloads.season": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Staffel laden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove download?" + "en": { + "stringUnit": { + "state": "translated", + "value": "Download season" } } } }, - "downloads.resume" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fortsetzen" + "downloads.used": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Belegt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resume" + "en": { + "stringUnit": { + "state": "translated", + "value": "Used" } } } }, - "downloads.season" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Staffel laden" + "downloads.wifiOnly": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur über WLAN laden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download season" + "en": { + "stringUnit": { + "state": "translated", + "value": "Download over Wi-Fi only" } } } }, - "downloads.used" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Belegt" + "episode.short %lld %lld": { + "comment": "Staffel- und Folgennummer", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "S%lld E%lld" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Used" + "en": { + "stringUnit": { + "state": "translated", + "value": "S%lld E%lld" } } } }, - "downloads.wifiOnly" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur über WLAN laden" + "error.noServers": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "An deinem Plex-Konto hängt kein Server." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download over Wi-Fi only" + "en": { + "stringUnit": { + "state": "translated", + "value": "There's no server on your Plex account." } } } }, - "episode.short %lld %lld" : { - "comment" : "Staffel- und Folgennummer", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "S%lld E%lld" + "error.notFound": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Titel ist auf dem Server nicht mehr vorhanden." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "S%lld E%lld" + "en": { + "stringUnit": { + "state": "translated", + "value": "This title is no longer on the server." } } } }, - "error.noServers" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "An deinem Plex-Konto hängt kein Server." + "error.server %lld": { + "comment": "Platzhalter: HTTP-Statuscode", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server hat mit Fehler %lld geantwortet." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "There's no server on your Plex account." + "en": { + "stringUnit": { + "state": "translated", + "value": "The server responded with error %lld." } } } }, - "error.notFound" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dieser Titel ist auf dem Server nicht mehr vorhanden." + "error.serverUnreachable": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server ist nicht erreichbar. Prüfe, ob er läuft und ob du im selben Netzwerk bist." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This title is no longer on the server." + "en": { + "stringUnit": { + "state": "translated", + "value": "The server can't be reached. Check that it's running and that you're on the same network." } } } }, - "error.server %lld" : { - "comment" : "Platzhalter: HTTP-Statuscode", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Der Server hat mit Fehler %lld geantwortet." + "error.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das hat nicht geklappt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The server responded with error %lld." + "en": { + "stringUnit": { + "state": "translated", + "value": "Something went wrong" } } } }, - "error.serverUnreachable" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Der Server ist nicht erreichbar. Prüfe, ob er läuft und ob du im selben Netzwerk bist." + "error.unauthorized": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Anmeldung ist abgelaufen. Melde dich erneut an." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The server can't be reached. Check that it's running and that you're on the same network." + "en": { + "stringUnit": { + "state": "translated", + "value": "Your sign-in has expired. Please sign in again." } } } }, - "error.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das hat nicht geklappt" + "error.unexpectedResponse": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Antwort des Servers war unerwartet. Möglicherweise ist eine neuere Plex-Version installiert." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Something went wrong" + "en": { + "stringUnit": { + "state": "translated", + "value": "The server's response was unexpected. It may be running a newer version of Plex." } } } }, - "error.unauthorized" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Anmeldung ist abgelaufen. Melde dich erneut an." + "filter.any": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your sign-in has expired. Please sign in again." + "en": { + "stringUnit": { + "state": "translated", + "value": "Any" } } } }, - "error.unexpectedResponse" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Antwort des Servers war unerwartet. Möglicherweise ist eine neuere Plex-Version installiert." + "filter.filters": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The server's response was unexpected. It may be running a newer version of Plex." + "en": { + "stringUnit": { + "state": "translated", + "value": "Filters" } } } }, - "filter.any" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alle" + "filter.genre": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Genre" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Any" + "en": { + "stringUnit": { + "state": "translated", + "value": "Genre" } } } }, - "filter.filters" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Filter" + "filter.reset": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Filters" + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" } } } }, - "filter.genre" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Genre" + "filter.resolution": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auflösung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Genre" + "en": { + "stringUnit": { + "state": "translated", + "value": "Resolution" } } } }, - "filter.reset" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurücksetzen" + "filter.sort": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortierung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reset" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort by" } } } }, - "filter.resolution" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auflösung" + "filter.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resolution" + "en": { + "stringUnit": { + "state": "translated", + "value": "Filters" } } } }, - "filter.sort" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sortierung" + "filter.unwatchedOnly": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Ungesehene" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sort by" + "en": { + "stringUnit": { + "state": "translated", + "value": "Unwatched only" } } } }, - "filter.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Filter" + "hero.details": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Details" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Filters" + "en": { + "stringUnit": { + "state": "translated", + "value": "Details" } } } }, - "filter.unwatchedOnly" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur Ungesehene" + "hero.play": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abspielen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unwatched only" + "en": { + "stringUnit": { + "state": "translated", + "value": "Play" } } } }, - "hero.details" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Details" + "hero.resume": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiterschauen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Details" + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume" } } } }, - "hero.play" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abspielen" + "home.empty.description": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sobald dein Server Mediatheken enthält, erscheinen sie hier." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Play" + "en": { + "stringUnit": { + "state": "translated", + "value": "Once your server has libraries, they'll show up here." } } } }, - "hero.resume" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Weiterschauen" + "home.empty.title": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch nichts da" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resume" + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing here yet" } } } }, - "home.empty.description" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sobald dein Server Mediatheken enthält, erscheinen sie hier." + "homeLayout.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server liefert derzeit keine Reihen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Once your server has libraries, they'll show up here." + "en": { + "stringUnit": { + "state": "translated", + "value": "The server isn't providing any rows right now." } } } }, - "home.empty.title" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Noch nichts da" + "homeLayout.explanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schalte Reihen aus, die du nicht brauchst, und sortiere sie über „Bearbeiten“ um. Neue Reihen vom Server erscheinen am Ende." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nothing here yet" + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn off rows you don't need and reorder them via Edit. New rows from the server appear at the end." } } } }, - "homeLayout.empty" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Der Server liefert derzeit keine Reihen." + "homeLayout.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Startseite" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The server isn't providing any rows right now." + "en": { + "stringUnit": { + "state": "translated", + "value": "Home screen" } } } }, - "homeLayout.explanation" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Schalte Reihen aus, die du nicht brauchst, und sortiere sie über „Bearbeiten“ um. Neue Reihen vom Server erscheinen am Ende." + "item.watched": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesehen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Turn off rows you don't need and reorder them via Edit. New rows from the server appear at the end." + "en": { + "stringUnit": { + "state": "translated", + "value": "Watched" } } } }, - "homeLayout.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Startseite" + "library.clearFilters": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter zurücksetzen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Home screen" + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear filters" } } } }, - "item.watched" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gesehen" + "library.empty.description": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit den aktuellen Filtern bleibt nichts übrig." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Watched" + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing matches the current filters." } } } }, - "library.clearFilters" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Filter zurücksetzen" + "library.empty.title": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Titel" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Clear filters" + "en": { + "stringUnit": { + "state": "translated", + "value": "No titles" } } } }, - "library.empty.description" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mit den aktuellen Filtern bleibt nichts übrig." + "library.filters": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nothing matches the current filters." + "en": { + "stringUnit": { + "state": "translated", + "value": "Filters" } } } }, - "library.empty.title" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Titel" + "library.scan": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mediathek durchsuchen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No titles" + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan library" } } } }, - "library.filters" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Filter" + "library.toggleLayout": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ansicht wechseln" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Filters" + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch layout" } } } }, - "library.scan" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mediathek durchsuchen" + "liveTV.channelCount %@": { + "comment": "A label displaying the number of channels. The argument is the number of channels.", + "isCommentAutoGenerated": true + }, + "liveTV.channelCount %lld": { + "comment": "Platzhalter: Anzahl Sender", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Sender" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scan library" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld channels" } } } }, - "library.toggleLayout" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ansicht wechseln" + "liveTV.gap": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Angaben" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Switch layout" + "en": { + "stringUnit": { + "state": "translated", + "value": "No data" } } } }, - "liveTV.channelCount %@" : { - "comment" : "A label displaying the number of channels. The argument is the number of channels.", - "isCommentAutoGenerated" : true + "liveTV.mode": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ansicht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View" + } + } + } }, - "liveTV.channelCount %lld" : { - "comment" : "Platzhalter: Anzahl Sender", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld Sender" + "liveTV.mode.guide": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Programm" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld channels" + "en": { + "stringUnit": { + "state": "translated", + "value": "Guide" } } } }, - "liveTV.gap" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Angaben" + "liveTV.mode.now": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No data" + "en": { + "stringUnit": { + "state": "translated", + "value": "Now" } } } }, - "liveTV.mode" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ansicht" + "liveTV.next %@ %@": { + "comment": "Platzhalter: Uhrzeit, Sendungstitel", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Danach %@ · %@" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "View" + "en": { + "stringUnit": { + "state": "translated", + "value": "Next %@ · %@" } } } }, - "liveTV.mode.guide" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Programm" + "liveTV.noChannels.description": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Empfänger ist eingerichtet, liefert aber keine Sender." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Guide" + "en": { + "stringUnit": { + "state": "translated", + "value": "The tuner is set up but isn't providing any channels." } } } }, - "liveTV.mode.now" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jetzt" + "liveTV.noChannels.title": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Sender" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Now" + "en": { + "stringUnit": { + "state": "translated", + "value": "No channels" } } } }, - "liveTV.next %@ %@" : { - "comment" : "Platzhalter: Uhrzeit, Sendungstitel", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Danach %@ · %@" + "liveTV.noProgram": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Programm verfügbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Next %@ · %@" + "en": { + "stringUnit": { + "state": "translated", + "value": "No guide data" } } } }, - "liveTV.noChannels.description" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Der Empfänger ist eingerichtet, liefert aber keine Sender." + "liveTV.noStream": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server hat keine Adresse für diesen Sender geliefert." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The tuner is set up but isn't providing any channels." + "en": { + "stringUnit": { + "state": "translated", + "value": "The server did not return a stream for this channel." } } } }, - "liveTV.noChannels.title" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Sender" + "liveTV.notConfigured.description": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Plex-Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No channels" + "en": { + "stringUnit": { + "state": "translated", + "value": "Live TV needs a tuner card or network tuner on your Plex server. Set one up in the server settings and channels will appear here." } } } }, - "liveTV.noProgram" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kein Programm verfügbar" + "liveTV.notConfigured.title": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Empfänger eingerichtet" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No guide data" + "en": { + "stringUnit": { + "state": "translated", + "value": "No tuner set up" } } } }, - "liveTV.noStream" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Der Server hat keine Adresse für diesen Sender geliefert." + "liveTV.now": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The server did not return a stream for this channel." + "en": { + "stringUnit": { + "state": "translated", + "value": "Now" } } } }, - "liveTV.notConfigured.description" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Plex-Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier." + "liveTV.runningNow": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft gerade" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Live TV needs a tuner card or network tuner on your Plex server. Set one up in the server settings and channels will appear here." + "en": { + "stringUnit": { + "state": "translated", + "value": "On now" } } } }, - "liveTV.notConfigured.title" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kein Empfänger eingerichtet" + "liveTV.timeRange %@ %@": { + "comment": "Platzhalter: Anfangs- und Endzeit", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ – %@" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No tuner set up" + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ – %@" } } } }, - "liveTV.now" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jetzt" + "liveTV.tune": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einschalten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Now" + "en": { + "stringUnit": { + "state": "translated", + "value": "Watch" } } } }, - "liveTV.runningNow" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Läuft gerade" + "liveTV.tuneFailed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sender lässt sich nicht einschalten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "On now" + "en": { + "stringUnit": { + "state": "translated", + "value": "Cannot tune channel" } } } }, - "liveTV.timeRange %@ %@" : { - "comment" : "Platzhalter: Anfangs- und Endzeit", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ – %@" + "miniPlayer.close": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wiedergabe beenden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ – %@" + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop playback" } } } }, - "liveTV.tune" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einschalten" + "miniPlayer.expand": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollbild" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Watch" + "en": { + "stringUnit": { + "state": "translated", + "value": "Full screen" } } } }, - "liveTV.tuneFailed" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sender lässt sich nicht einschalten" + "placeholder.description": { + "comment": "Platzhalter-Bildschirme", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Bereich entsteht in einer späteren Phase." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cannot tune channel" + "en": { + "stringUnit": { + "state": "translated", + "value": "This area is coming in a later phase." } } } }, - "placeholder.description" : { - "comment" : "Platzhalter-Bildschirme", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dieser Bereich entsteht in einer späteren Phase." + "player.audioTracks": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tonspur" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This area is coming in a later phase." + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio" } } } }, - "player.audioTracks" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tonspur" + "player.back10": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "10 Sekunden zurück" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Audio" + "en": { + "stringUnit": { + "state": "translated", + "value": "Back 10 seconds" } } } }, - "player.back10" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "10 Sekunden zurück" + "player.close": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Player schließen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back 10 seconds" + "en": { + "stringUnit": { + "state": "translated", + "value": "Close player" } } } }, - "player.close" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Player schließen" + "player.forward10": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "10 Sekunden vor" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close player" + "en": { + "stringUnit": { + "state": "translated", + "value": "Forward 10 seconds" } } } }, - "player.forward10" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "10 Sekunden vor" + "player.loadFailed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Datei ließ sich nicht öffnen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Forward 10 seconds" + "en": { + "stringUnit": { + "state": "translated", + "value": "The file couldn't be opened." } } } }, - "player.loadFailed" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Datei ließ sich nicht öffnen." + "player.minimize": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verkleinern" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The file couldn't be opened." + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimize" } } } }, - "player.nextEpisode" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nächste Folge" + "player.nextEpisode": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächste Folge" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Next episode" + "en": { + "stringUnit": { + "state": "translated", + "value": "Next episode" } } } }, - "player.noPlayableFile" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zu diesem Titel gibt es keine abspielbare Datei auf dem Server." + "player.noPlayableFile": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu diesem Titel gibt es keine abspielbare Datei auf dem Server." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "There's no playable file for this title on the server." + "en": { + "stringUnit": { + "state": "translated", + "value": "There's no playable file for this title on the server." } } } }, - "player.pause" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pause" + "player.pause": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Pause" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pause" + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause" } } } }, - "player.play" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abspielen" + "player.play": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abspielen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Play" + "en": { + "stringUnit": { + "state": "translated", + "value": "Play" } } } }, - "player.playbackFailed" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Wiedergabe wurde unterbrochen." + "player.playbackFailed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Wiedergabe wurde unterbrochen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Playback was interrupted." + "en": { + "stringUnit": { + "state": "translated", + "value": "Playback was interrupted." } } } }, - "player.preparing" : { - "comment" : "Hinweis, während der Player die Datei öffnet", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wird vorbereitet …" + "player.preparing": { + "comment": "Hinweis, während der Player die Datei öffnet", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird vorbereitet …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Preparing…" + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing…" } } } }, - "player.quality" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Qualität" + "player.quality": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Qualität" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quality" + "en": { + "stringUnit": { + "state": "translated", + "value": "Quality" } } } }, - "player.relayWarning" : { - "comment" : "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an." + "player.relayWarning": { + "comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it." + "en": { + "stringUnit": { + "state": "translated", + "value": "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it." } } } }, - "player.skipCredits" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abspann überspringen" + "player.skipCredits": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abspann überspringen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skip credits" + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip credits" } } } }, - "player.skipIntro" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Intro überspringen" + "player.skipIntro": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Intro überspringen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skip intro" + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip intro" } } } }, - "player.subtitlesOff" : { - "comment" : "Eintrag, der die Untertitel abschaltet", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aus" + "player.stop": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Beenden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Off" + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" } } } }, - "player.subtitleTracks" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Untertitel" + "player.subtitleTracks": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Untertitel" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Subtitles" + "en": { + "stringUnit": { + "state": "translated", + "value": "Subtitles" } } } }, - "player.tracks" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ton und Untertitel" + "player.subtitlesOff": { + "comment": "Eintrag, der die Untertitel abschaltet", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Audio and subtitles" + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" } } } }, - "player.transcodeRefused" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Der Server hat die Wiedergabe abgelehnt." + "player.tracks": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ton und Untertitel" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The server refused playback." + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio and subtitles" } } } }, - "quality.automatic" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatisch" + "player.transcodeRefused": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server hat die Wiedergabe abgelehnt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatic" + "en": { + "stringUnit": { + "state": "translated", + "value": "The server refused playback." } } } }, - "quality.fullHD" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Full HD · 1080p" + "quality.automatic": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Full HD · 1080p" + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" } } } }, - "quality.hd" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "HD · 720p" + "quality.fullHD": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Full HD · 1080p" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "HD · 720p" + "en": { + "stringUnit": { + "state": "translated", + "value": "Full HD · 1080p" } } } }, - "quality.high" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hoch (12 Mbit/s)" + "quality.hd": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "HD · 720p" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "High (12 Mbps)" + "en": { + "stringUnit": { + "state": "translated", + "value": "HD · 720p" } } } }, - "quality.low" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niedrig (4 Mbit/s)" + "quality.high": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hoch (12 Mbit/s)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Low (4 Mbps)" + "en": { + "stringUnit": { + "state": "translated", + "value": "High (12 Mbps)" } } } }, - "quality.lowRes" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sparsam · 480p" + "quality.low": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Niedrig (4 Mbit/s)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Data saver · 480p" + "en": { + "stringUnit": { + "state": "translated", + "value": "Low (4 Mbps)" } } } }, - "quality.medium" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mittel (8 Mbit/s)" + "quality.lowRes": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sparsam · 480p" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medium (8 Mbps)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Data saver · 480p" } } } }, - "quality.minimal" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sparsam (2 Mbit/s)" + "quality.medium": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel (8 Mbit/s)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Data saver (2 Mbps)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium (8 Mbps)" } } } }, - "quality.original" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Original" + "quality.minimal": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sparsam (2 Mbit/s)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Original" + "en": { + "stringUnit": { + "state": "translated", + "value": "Data saver (2 Mbps)" } } } }, - "runtime.hoursMinutes %lld %lld" : { - "comment" : "Laufzeit mit Stunden", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld Std %lld Min" + "quality.original": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Original" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldh %lldm" + "en": { + "stringUnit": { + "state": "translated", + "value": "Original" } } } }, - "runtime.minutes %lld" : { - "comment" : "Laufzeit unter einer Stunde", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld Min" + "runtime.hoursMinutes %lld %lld": { + "comment": "Laufzeit mit Stunden", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Std %lld Min" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldm" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lldh %lldm" } } } }, - "SD" : { - "comment" : "A label for a 720p movie.", - "isCommentAutoGenerated" : true + "runtime.minutes %lld": { + "comment": "Laufzeit unter einer Stunde", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Min" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lldm" + } + } + } }, - "search.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Suche nach Filmen, Serien, Folgen und Personen." + "search.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche nach Filmen, Serien, Folgen und Personen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Search for movies, shows, episodes and people." + "en": { + "stringUnit": { + "state": "translated", + "value": "Search for movies, shows, episodes and people." } } } }, - "search.recent" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zuletzt gesucht" + "search.recent": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt gesucht" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Recent searches" + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent searches" } } } }, - "search.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Suche" + "search.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Search" + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" } } } }, - "season.short %lld" : { - "comment" : "Platzhalter: Staffelnummer", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Staffel %lld" + "season.short %lld": { + "comment": "Platzhalter: Staffelnummer", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Staffel %lld" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Season %lld" + "en": { + "stringUnit": { + "state": "translated", + "value": "Season %lld" } } } }, - "serverPicker.owned" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eigener Server" + "serverPicker.owned": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eigener Server" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your server" + "en": { + "stringUnit": { + "state": "translated", + "value": "Your server" } } } }, - "serverPicker.shared" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Geteilt" + "serverPicker.shared": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Geteilt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Shared" + "en": { + "stringUnit": { + "state": "translated", + "value": "Shared" } } } }, - "serverPicker.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server wählen" + "serverPicker.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Server wählen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose a server" + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a server" } } } }, - "settings.address" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adresse" + "settings.address": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Adresse" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Address" + "en": { + "stringUnit": { + "state": "translated", + "value": "Address" } } } }, - "settings.app" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "App" + "settings.app": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "App" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "App" + "en": { + "stringUnit": { + "state": "translated", + "value": "App" } } } }, - "settings.autoPlayNext" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nächste Folge automatisch" + "settings.autoPlayNext": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächste Folge automatisch" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Play next episode automatically" + "en": { + "stringUnit": { + "state": "translated", + "value": "Play next episode automatically" } } } }, - "settings.autoSkipCredits" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abspann automatisch überspringen" + "settings.autoSkipCredits": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abspann automatisch überspringen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skip credits automatically" + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip credits automatically" } } } }, - "settings.autoSkipIntro" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Intro automatisch überspringen" + "settings.autoSkipIntro": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Intro automatisch überspringen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skip intros automatically" + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip intros automatically" } } } }, - "settings.build" : { - "comment" : "Zeitpunkt, zu dem der laufende Build entstanden ist", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Build" + "settings.build": { + "comment": "Zeitpunkt, zu dem der laufende Build entstanden ist", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Build" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Build" + "en": { + "stringUnit": { + "state": "translated", + "value": "Build" } } } }, - "settings.connection" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbindung" + "settings.connection": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connection" + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection" } } } }, - "settings.connectionLocal" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lokal" + "settings.connectionLocal": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lokal" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Local" + "en": { + "stringUnit": { + "state": "translated", + "value": "Local" } } } }, - "settings.connectionRelay" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Über Plex-Vermittlung (gedrosselt)" + "settings.connectionRelay": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Über Plex-Vermittlung (gedrosselt)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Via Plex relay (throttled)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Via Plex relay (throttled)" } } } }, - "settings.connectionRemote" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Direkt von außen" + "settings.connectionRemote": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Direkt von außen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Direct remote" + "en": { + "stringUnit": { + "state": "translated", + "value": "Direct remote" } } } }, - "settings.deleteAfterDay" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nach einem Tag" + "settings.deleteAfterDay": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach einem Tag" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "After a day" + "en": { + "stringUnit": { + "state": "translated", + "value": "After a day" } } } }, - "settings.deleteAfterWeek" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nach einer Woche" + "settings.deleteAfterWeek": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach einer Woche" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "After a week" + "en": { + "stringUnit": { + "state": "translated", + "value": "After a week" } } } }, - "settings.deleteImmediately" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sofort" + "settings.deleteImmediately": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sofort" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Immediately" + "en": { + "stringUnit": { + "state": "translated", + "value": "Immediately" } } } }, - "settings.deleteNever" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie" + "settings.deleteNever": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nie" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Never" + "en": { + "stringUnit": { + "state": "translated", + "value": "Never" } } } }, - "settings.deleteWatched" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gesehenes löschen" + "settings.deleteWatched": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesehenes löschen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delete watched" + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete watched" } } } }, - "settings.downloadsExplanation" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fehlende Folgen werden nachgeladen, sobald du eine gesehen hast. Gesehenes wird nach der eingestellten Frist entfernt." + "settings.downloadsExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlende Folgen werden nachgeladen, sobald du eine gesehen hast. Gesehenes wird nach der eingestellten Frist entfernt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Missing episodes are downloaded once you finish one. Watched titles are removed after the chosen delay." + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing episodes are downloaded once you finish one. Watched titles are removed after the chosen delay." } } } }, - "settings.keepCount %@" : { - "comment" : "A label that lets the user choose how many episodes of a series to keep after they've finished watching them all. The argument is the number of episodes to keep.", - "isCommentAutoGenerated" : true + "settings.keepCount %@": { + "comment": "A label that lets the user choose how many episodes of a series to keep after they've finished watching them all. The argument is the number of episodes to keep.", + "isCommentAutoGenerated": true }, - "settings.keepCount %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld ungesehene" + "settings.keepCount %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld ungesehene" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld unwatched" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld unwatched" } } } }, - "settings.keepOff" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aus" + "settings.keepOff": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Off" + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" } } } }, - "settings.keepUnwatched" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Folgen vorhalten" + "settings.keepUnwatched": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Folgen vorhalten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keep episodes ready" + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep episodes ready" } } } }, - "settings.libraries" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mediatheken" + "settings.libraries": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mediatheken" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Libraries" + "en": { + "stringUnit": { + "state": "translated", + "value": "Libraries" } } } }, - "settings.playback" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wiedergabe" + "settings.playback": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wiedergabe" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Playback" + "en": { + "stringUnit": { + "state": "translated", + "value": "Playback" } } } }, - "settings.quality" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Qualität" + "settings.quality": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Qualität" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quality" + "en": { + "stringUnit": { + "state": "translated", + "value": "Quality" } } } }, - "settings.qualityCellular" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Über Mobilfunk" + "settings.qualityCellular": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Über Mobilfunk" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "On cellular" + "en": { + "stringUnit": { + "state": "translated", + "value": "On cellular" } } } }, - "settings.qualityExplanation" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wird eine Datei größer als die eingestellte Grenze, rechnet der Server sie unterwegs herunter und passt die Bitrate laufend an die Verbindung an." + "settings.qualityExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird eine Datei größer als die eingestellte Grenze, rechnet der Server sie unterwegs herunter und passt die Bitrate laufend an die Verbindung an." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "If a file exceeds the limit, the server transcodes it on the fly and adapts the bitrate to your connection." + "en": { + "stringUnit": { + "state": "translated", + "value": "If a file exceeds the limit, the server transcodes it on the fly and adapts the bitrate to your connection." } } } }, - "settings.qualityWifi" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Im WLAN" + "settings.qualityWifi": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Im WLAN" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "On Wi-Fi" + "en": { + "stringUnit": { + "state": "translated", + "value": "On Wi-Fi" } } } }, - "settings.server" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server" + "settings.server": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Server" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Server" + "en": { + "stringUnit": { + "state": "translated", + "value": "Server" } } } }, - "settings.serverName" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Name" + "settings.serverName": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Name" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Name" + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" } } } }, - "settings.signOutConfirm" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wirklich abmelden?" + "settings.signOutConfirm": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wirklich abmelden?" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign out?" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out?" } } } }, - "settings.signOutExplanation" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Token wird vom Gerät entfernt. Heruntergeladene Titel bleiben erhalten." + "settings.signOutExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Token wird vom Gerät entfernt. Heruntergeladene Titel bleiben erhalten." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The token will be removed from this device. Downloaded titles are kept." + "en": { + "stringUnit": { + "state": "translated", + "value": "The token will be removed from this device. Downloaded titles are kept." } } } }, - "settings.storageLimit" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Speicherlimit" + "settings.storageLimit": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Speicherlimit" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Storage limit" + "en": { + "stringUnit": { + "state": "translated", + "value": "Storage limit" } } } }, - "settings.storageUnlimited" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unbegrenzt" + "settings.storageUnlimited": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbegrenzt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unlimited" + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlimited" } } } }, - "settings.version" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Version" + "settings.version": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Version" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Version" + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" } } } }, - "sidebar.close" : { - "comment" : "Barrierefreiheit: abgedunkelte Fläche", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seitenleiste schließen" + "sidebar.close": { + "comment": "Barrierefreiheit: abgedunkelte Fläche", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste schließen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close sidebar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Close sidebar" } } } }, - "sidebar.downloads" : { - "comment" : "Eintrag in der Seitenleiste", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Downloads" + "sidebar.downloads": { + "comment": "Eintrag in der Seitenleiste", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Downloads" + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloads" } } } }, - "sidebar.home" : { - "comment" : "Eintrag der Startseite in der Seitenleiste", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start" + "sidebar.home": { + "comment": "Eintrag der Startseite in der Seitenleiste", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Start" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Home" + "en": { + "stringUnit": { + "state": "translated", + "value": "Home" } } } }, - "sidebar.libraries" : { - "comment" : "Überschrift über der Liste der Mediatheken", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mediatheken" + "sidebar.libraries": { + "comment": "Überschrift über der Liste der Mediatheken", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mediatheken" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Libraries" + "en": { + "stringUnit": { + "state": "translated", + "value": "Libraries" } } } }, - "sidebar.liveTV" : { - "comment" : "Eintrag in der Seitenleiste", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Live-TV" + "sidebar.liveTV": { + "comment": "Eintrag in der Seitenleiste", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-TV" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Live TV" + "en": { + "stringUnit": { + "state": "translated", + "value": "Live TV" } } } }, - "sidebar.more" : { - "comment" : "Überschrift über Live-TV, Downloads und Einstellungen", - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mehr" + "sidebar.more": { + "comment": "Überschrift über Live-TV, Downloads und Einstellungen", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mehr" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "More" + "en": { + "stringUnit": { + "state": "translated", + "value": "More" } } } }, - "sidebar.open" : { - "comment" : "Barrierefreiheit: Hamburger-Knopf", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seitenleiste öffnen" + "sidebar.open": { + "comment": "Barrierefreiheit: Hamburger-Knopf", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open sidebar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open sidebar" } } } }, - "sidebar.settings" : { - "comment" : "Eintrag in der Seitenleiste", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einstellungen" + "sidebar.settings": { + "comment": "Eintrag in der Seitenleiste", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" } } } }, - "signIn.action" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mit Plex anmelden" + "signIn.action": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Plex anmelden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign in with Plex" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with Plex" } } } }, - "signIn.cancelled" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anmeldung abgebrochen." + "signIn.cancelled": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldung abgebrochen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign-in cancelled." + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in cancelled." } } } }, - "signIn.manualHint" : { - "comment" : "Hinweis, wenn die Browser-Verknüpfung nicht greift", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hat das Fenster nicht verknüpft? Öffne plex.tv/link und gib diesen Code ein:" + "signIn.manualHint": { + "comment": "Hinweis, wenn die Browser-Verknüpfung nicht greift", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hat das Fenster nicht verknüpft? Öffne plex.tv/link und gib diesen Code ein:" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Window didn't link? Open plex.tv/link and enter this code:" + "en": { + "stringUnit": { + "state": "translated", + "value": "Window didn't link? Open plex.tv/link and enter this code:" } } } }, - "signIn.privacy" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Anmeldemaske kommt von Plex. Dein Passwort erreicht diese App nie." + "signIn.privacy": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Anmeldemaske kommt von Plex. Dein Passwort erreicht diese App nie." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The sign-in page comes from Plex. Your password never reaches this app." + "en": { + "stringUnit": { + "state": "translated", + "value": "The sign-in page comes from Plex. Your password never reaches this app." } } } }, - "signIn.tagline" : { - "comment" : "Untertitel auf dem Anmeldebildschirm", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deine Plex-Mediathek, schöner." + "signIn.tagline": { + "comment": "Untertitel auf dem Anmeldebildschirm", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine Plex-Mediathek, schöner." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your Plex library, nicer." + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Plex library, nicer." } } } }, - "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." + "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." + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in wasn't completed. Please try again." } } } }, - "signIn.windowFailed" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Anmeldefenster ließ sich nicht öffnen." + "signIn.windowFailed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Anmeldefenster ließ sich nicht öffnen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The sign-in window couldn't be opened." + "en": { + "stringUnit": { + "state": "translated", + "value": "The sign-in window couldn't be opened." } } } }, - "sort.lastViewed" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zuletzt gesehen" + "sort.lastViewed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt gesehen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last watched" + "en": { + "stringUnit": { + "state": "translated", + "value": "Last watched" } } } }, - "sort.rating" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bewertung" + "sort.rating": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bewertung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rating" + "en": { + "stringUnit": { + "state": "translated", + "value": "Rating" } } } }, - "sort.recentlyAdded" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zuletzt hinzugefügt" + "sort.recentlyAdded": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt hinzugefügt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Recently added" + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently added" } } } }, - "sort.releaseDate" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erscheinungsjahr" + "sort.releaseDate": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erscheinungsjahr" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Release date" + "en": { + "stringUnit": { + "state": "translated", + "value": "Release date" } } } }, - "sort.titleAscending" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Titel (A–Z)" + "sort.titleAscending": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel (A–Z)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Title (A–Z)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Title (A–Z)" } } } }, - "sort.titleDescending" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Titel (Z–A)" + "sort.titleDescending": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel (Z–A)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Title (Z–A)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Title (Z–A)" } } } }, - "technical.audio" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tonspur" + "technical.audio": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tonspur" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Audio" + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio" } } } }, - "technical.bitrate" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bitrate" + "technical.bitrate": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bitrate" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bitrate" + "en": { + "stringUnit": { + "state": "translated", + "value": "Bitrate" } } } }, - "technical.container" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Container" + "technical.container": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Container" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Container" + "en": { + "stringUnit": { + "state": "translated", + "value": "Container" } } } }, - "technical.directPlayNative" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Direkt (Systemplayer)" + "technical.directPlayNative": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Direkt (Systemplayer)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Direct (system player)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Direct (system player)" } } } }, - "technical.directPlayVLC" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Direkt (VLC)" + "technical.directPlayVLC": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Direkt (VLC)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Direct (VLC)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Direct (VLC)" } } } }, - "technical.file" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Datei auf dem Server" + "technical.file": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Datei auf dem Server" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "File on server" + "en": { + "stringUnit": { + "state": "translated", + "value": "File on server" } } } }, - "technical.playbackMethod" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wiedergabe" + "technical.playbackMethod": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wiedergabe" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Playback" + "en": { + "stringUnit": { + "state": "translated", + "value": "Playback" } } } }, - "technical.reason" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Grund" + "technical.reason": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Grund" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reason" + "en": { + "stringUnit": { + "state": "translated", + "value": "Reason" } } } }, - "technical.reason.audioCodec %@" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "iOS kann %@-Ton nicht wiedergeben." + "technical.reason.audioCodec %@": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "iOS kann %@-Ton nicht wiedergeben." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "iOS can't play %@ audio." + "en": { + "stringUnit": { + "state": "translated", + "value": "iOS can't play %@ audio." } } } }, - "technical.reason.container %@" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "iOS öffnet den Container %@ nicht." + "technical.reason.container %@": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "iOS öffnet den Container %@ nicht." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "iOS can't open the %@ container." + "en": { + "stringUnit": { + "state": "translated", + "value": "iOS can't open the %@ container." } } } }, - "technical.reason.unanalyzed" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Der Server hat diese Datei noch nicht analysiert." + "technical.reason.unanalyzed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server hat diese Datei noch nicht analysiert." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The server hasn't analyzed this file yet." + "en": { + "stringUnit": { + "state": "translated", + "value": "The server hasn't analyzed this file yet." } } } }, - "technical.reason.videoCodec %@" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "iOS kann %@ nicht dekodieren." + "technical.reason.videoCodec %@": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "iOS kann %@ nicht dekodieren." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "iOS can't decode %@." + "en": { + "stringUnit": { + "state": "translated", + "value": "iOS can't decode %@." } } } }, - "technical.resolution" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auflösung" + "technical.resolution": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auflösung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resolution" + "en": { + "stringUnit": { + "state": "translated", + "value": "Resolution" } } } }, - "technical.size" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dateigröße" + "technical.size": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dateigröße" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "File size" + "en": { + "stringUnit": { + "state": "translated", + "value": "File size" } } } }, - "technical.subtitle" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Untertitel" + "technical.subtitle": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Untertitel" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Subtitle" + "en": { + "stringUnit": { + "state": "translated", + "value": "Subtitle" } } } }, - "technical.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Technische Angaben" + "technical.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Technische Angaben" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Technical details" + "en": { + "stringUnit": { + "state": "translated", + "value": "Technical details" } } } }, - "technical.transcode" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Umgerechnet vom Server" + "technical.transcode": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Umgerechnet vom Server" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Transcoded by server" + "en": { + "stringUnit": { + "state": "translated", + "value": "Transcoded by server" } } } }, - "technical.videoCodec" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bildcodec" + "technical.videoCodec": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildcodec" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Video codec" + "en": { + "stringUnit": { + "state": "translated", + "value": "Video codec" } } } } }, - "version" : "1.1" + "version": "1.1" } \ No newline at end of file diff --git a/Flixy/Session/PlaybackCoordinator.swift b/Flixy/Session/PlaybackCoordinator.swift index 0cd6f67..a3ebb8c 100644 --- a/Flixy/Session/PlaybackCoordinator.swift +++ b/Flixy/Session/PlaybackCoordinator.swift @@ -36,19 +36,78 @@ struct PlaybackRequest: Identifiable, Hashable { @MainActor @Observable final class PlaybackCoordinator { - var request: PlaybackRequest? + /// Wie der Player gerade zu sehen ist. + /// + /// Der Unterschied zwischen „klein" und „gar nicht" ist der Kern der + /// Sache: verkleinern darf die Wiedergabe **nicht** anhalten. Vorher gab + /// es diese Unterscheidung nicht — der Player war da oder weg, und mit + /// ihm der Motor, der Transcoder und beim Live-TV der belegte Tuner. + enum Presentation: Equatable { + case hidden + case mini + case full + } + + private(set) var request: PlaybackRequest? + private(set) var presentation: Presentation = .hidden func play(_ item: PlexItem, mediaIndex: Int = 0, resuming: Bool = true) { - request = PlaybackRequest( + play(PlaybackRequest( ratingKey: item.ratingKey, title: item.playbackTitle, mediaIndex: mediaIndex, startTime: resuming ? item.resumeTime : .zero - ) + )) } - func dismiss() { + /// Schaltet einen laufenden Sender ein. + /// + /// Eigener Weg, weil ein Sender nicht in der Mediathek liegt: Adresse und + /// Tuner-Belegung stehen schon fest und dürfen nicht neu ermittelt werden. + func playLive( + channelID: String, + title: String, + stream: URL, + subscriptionKey: String? + ) { + play(PlaybackRequest( + ratingKey: channelID, + title: title, + mediaIndex: 0, + startTime: .zero, + streamURL: stream, + liveSubscriptionKey: subscriptionKey + )) + } + + /// Übernimmt einen neuen Titel. + /// + /// Läuft bereits die Kachel, bleibt es dabei: wer nebenher stöbert, will + /// beim Antippen nicht ins Vollbild gerissen werden. Sonst Vollbild, wie + /// man es von einem Antippen erwartet. + func play(_ next: PlaybackRequest) { + presentation = presentation == .mini ? .mini : .full + request = next + } + + /// Verkleinert den Player zur schwebenden Kachel. Die Wiedergabe läuft. + func minimize() { + guard request != nil else { return } + presentation = .mini + } + + func expand() { + guard request != nil else { return } + presentation = .full + } + + /// Beendet die Wiedergabe wirklich. + /// + /// Die einzige Stelle, an der das passiert. Alles andere — verkleinern, + /// vergrößern, Bild-in-Bild — lässt den Motor am Leben. + func close() { request = nil + presentation = .hidden } } diff --git a/Flixy/Views/Downloads/DownloadsView.swift b/Flixy/Views/Downloads/DownloadsView.swift index 55be2c4..997c13d 100644 --- a/Flixy/Views/Downloads/DownloadsView.swift +++ b/Flixy/Views/Downloads/DownloadsView.swift @@ -114,12 +114,12 @@ struct DownloadsView: View { private func play(_ item: DownloadItem) { guard item.state == .completed else { return } - playback.request = PlaybackRequest( + playback.play(PlaybackRequest( ratingKey: item.ratingKey, title: item.title, mediaIndex: 0, startTime: .zero - ) + )) } } diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index c2c9be3..2bead33 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -226,13 +226,11 @@ struct LiveTVView: View { // Live-TV läuft über denselben Player wie alles andere, bekommt aber // die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht. - playback.request = PlaybackRequest( - ratingKey: channel.id, + playback.playLive( + channelID: channel.id, title: channel.title, - mediaIndex: 0, - startTime: .zero, - streamURL: stream, - liveSubscriptionKey: result.subscriptionKey + stream: stream, + subscriptionKey: result.subscriptionKey ) } } diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index fa5d5d2..27c5d77 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -1,5 +1,7 @@ import SwiftUI import FlixyDesign +import FlixyDownloads +import FlixyPlayback import PlexKit /// Die Wurzelansicht. @@ -12,6 +14,17 @@ struct RootView: View { @Environment(FlixySession.self) private var session @Environment(FlixyTheme.self) private var theme @Environment(PlaybackCoordinator.self) private var playback + @Environment(PlaybackSettings.self) private var settings + @Environment(NetworkConditions.self) private var network + @Environment(DownloadCoordinator.self) private var downloads + @Environment(PlaybackDiagnostics.self) private var diagnostics + + /// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist. + /// + /// Damit überlebt die Wiedergabe den Wechsel zwischen Kachel und + /// Vollbild. Läge das Modell im Player selbst, stürbe es mit ihm, und + /// genau das ist vorher passiert. + @State private var player: PlayerModel? @Environment(\.horizontalSizeClass) private var sizeClass @State private var selection: SidebarDestination = .home @@ -61,8 +74,20 @@ struct RootView: View { .tint(theme.accentColor) // Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich // etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs. - .fullScreenCover(item: Bindable(playback).request) { request in - PlayerView(request: request) + // Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und + // die Bildfläche müsste bei jedem Wechsel zwischen Kachel und + // Vollbild umziehen. Als Schicht im selben Baum bleibt sie, wo sie + // ist, und der Übergang ist ruhig. + .overlay { + if playback.presentation == .full, let player { + PlayerView(model: player) + .ignoresSafeArea() + .transition(.move(edge: .bottom)) + } + } + .animation(.snappy(duration: 0.3), value: playback.presentation) + .onChange(of: playback.request) { _, request in + Task { await handOver(to: request) } } .task { await session.restore() } .onChange(of: session.phase) { _, newPhase in @@ -72,6 +97,42 @@ struct RootView: View { } } + /// Wechselt den laufenden Titel. + /// + /// Das Neue wird zuerst aufgebaut, das Alte **nebenher** beendet. Anders + /// herum säße man nach dem Antippen sekundenlang vor einem leeren Bild: + /// das Abräumen spricht mit dem Server — Transcoder anhalten, beim + /// Live-TV den Tuner freigeben —, und das darf den neuen Titel nicht + /// aufhalten. Ein kurzzeitig doppelt belegter Tuner ist der kleinere + /// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon + /// abgestimmt, bevor es hier ankommt. + private func handOver(to request: PlaybackRequest?) async { + let previous = player + + guard let request else { + player = nil + if let previous { await previous.stop() } + return + } + + let fresh = PlayerModel( + request: request, + server: session.server, + downloads: downloads, + diagnostics: diagnostics, + policy: settings.policy(for: network), + settings: settings, + isLocalConnection: session.server?.connection.local ?? false + ) + // Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in + // der Kachel — dort kam man vorher gar nicht mehr heraus. + fresh.onPictureInPictureRestore { playback.expand() } + + player = fresh + if let previous { Task { await previous.stop() } } + await fresh.start() + } + @ViewBuilder private var library: some View { if sizeClass == .regular { @@ -96,6 +157,7 @@ struct RootView: View { .navigationTitle(Branding.appName) } detail: { NavigationStack { destination } + .miniPlayer(player, isVisible: playback.presentation == .mini) } } @@ -119,6 +181,9 @@ struct RootView: View { } } } + // Innerhalb des Inhalts, nicht darüber: fährt die Seitenleiste + // aus, soll sie die Kachel verdecken und nicht umgekehrt. + .miniPlayer(player, isVisible: playback.presentation == .mini) } } diff --git a/Flixy/Views/Player/MiniPlayer.swift b/Flixy/Views/Player/MiniPlayer.swift new file mode 100644 index 0000000..3e2a414 --- /dev/null +++ b/Flixy/Views/Player/MiniPlayer.swift @@ -0,0 +1,190 @@ +import SwiftUI +import FlixyDesign + +/// Die schwebende Videokachel. +/// +/// Damit läuft weiter, was man gerade sieht, während man nach dem Nächsten +/// stöbert — der Grund, warum es sie gibt. Plex' eigene Handy-Apps reißen die +/// Wiedergabe beim Zurückgehen ab, und genau in dem Moment will man +/// weiterschauen und nebenher nachsehen, was sonst noch läuft. +/// +/// Bewusst schwebend statt als Leiste am unteren Rand: eine Leiste nimmt jeder +/// Liste Platz weg und müsste in jedem Bildschirm berücksichtigt werden. Eine +/// Kachel schwebt darüber, und wem sie im Weg ist, der zieht sie weg. +struct MiniPlayer: View { + let model: PlayerModel + + @Environment(PlaybackCoordinator.self) private var playback + @Environment(\.horizontalSizeClass) private var sizeClass + + /// Die Ecke bleibt über Starts hinweg erhalten. Wer die Kachel einmal + /// nach links unten gezogen hat, will sie beim nächsten Mal dort haben. + @AppStorage("miniPlayer.corner") private var storedCorner = MiniPlayerPlacement.Corner.bottomTrailing.rawValue + + /// Verschiebung während des Ziehens, noch nicht eingerastet. + @State private var drag: CGSize = .zero + @State private var showsControls = false + @State private var hideTask: Task? + + private var corner: MiniPlayerPlacement.Corner { + MiniPlayerPlacement.Corner(rawValue: storedCorner) ?? .bottomTrailing + } + + /// Auf dem iPad darf sie größer sein — dort ist genug Fläche, und bei + /// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts. + private var tileWidth: CGFloat { sizeClass == .regular ? 280 : 180 } + private var tileSize: CGSize { + CGSize(width: tileWidth, height: (tileWidth / FlixyAspectRatio.landscape).rounded()) + } + + var body: some View { + GeometryReader { geometry in + let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize) + let resting = placement.center(for: corner) + let position = placement.clamped( + center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height) + ) + + tile + .frame(width: tileSize.width, height: tileSize.height) + .position(position) + .gesture(dragGesture(in: placement, from: resting)) + // Nur während des Ziehens ohne Animation, sonst würde die + // Kachel dem Finger hinterherfedern statt an ihm zu kleben. + .animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position) + } + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } + + private var tile: some View { + ZStack { + Color.black + + VideoSurface(engine: model.engine, surface: model.surface) + + if model.isBuffering { + ProgressView().tint(.white) + } + + if showsControls { + controls.transition(.opacity) + } + + // Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung — + // es gibt kein Ende, auf das er zulaufen könnte. + if !model.isLive, let fraction = progress { + VStack { + Spacer() + WatchProgressBar(fraction: fraction) + } + } + } + .clipShape(.rect(cornerRadius: FlixyRadius.card)) + .overlay { + RoundedRectangle(cornerRadius: FlixyRadius.card) + .strokeBorder(Color.flixySeparator, lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.5), radius: 12, y: 4) + .contentShape(.rect) + .onTapGesture { + // Antippen zeigt erst die Bedienelemente, dann öffnet es. Ein + // sofortiges Aufklappen bei jeder Berührung wäre lästig, wenn man + // die Kachel nur anfassen will. + if showsControls { + playback.expand() + } else { + reveal() + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Text(model.title)) + } + + private var controls: some View { + ZStack { + Color.black.opacity(0.45) + + Button { + model.togglePlayPause() + reveal() + } label: { + Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") + .font(.title2) + .foregroundStyle(.white) + .padding(FlixySpacing.snug) + .contentShape(.rect) + } + .accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play")) + + VStack { + HStack { + Spacer() + Button { + playback.close() + } label: { + Image(systemName: "xmark") + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .padding(FlixySpacing.snug) + .contentShape(.rect) + } + .accessibilityLabel(Text("miniPlayer.close")) + } + Spacer() + } + } + } + + private var progress: Double? { + guard let duration = model.duration, duration.seconds > 0 else { return nil } + return model.currentTime.seconds / duration.seconds + } + + private func dragGesture( + in placement: MiniPlayerPlacement, + from resting: CGPoint + ) -> some Gesture { + DragGesture() + .onChanged { value in + hideTask?.cancel() + drag = value.translation + } + .onEnded { value in + // Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach + // dem Finger: sonst springt eine Kachel, die man an der Kante + // angefasst hat, in die falsche Hälfte. + let center = CGPoint( + x: resting.x + value.predictedEndTranslation.width, + y: resting.y + value.predictedEndTranslation.height + ) + storedCorner = placement.nearestCorner(toCenter: center).rawValue + drag = .zero + } + } + + /// Zeigt die Bedienelemente und blendet sie nach kurzer Zeit wieder aus. + private func reveal() { + withAnimation(.easeInOut(duration: 0.2)) { showsControls = true } + hideTask?.cancel() + hideTask = Task { + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.2)) { showsControls = false } + } + } +} + +extension View { + /// Legt die schwebende Kachel über diesen Bildschirm. + /// + /// Bewusst hier und nicht ganz oben in der Wurzel: auf dem iPhone soll die + /// Seitenleiste die Kachel verdecken, wenn sie aufgeht, und nicht + /// umgekehrt. Deshalb liegt sie **innerhalb** des Inhalts. + func miniPlayer(_ model: PlayerModel?, isVisible: Bool) -> some View { + overlay { + if let model, isVisible { + MiniPlayer(model: model) + } + } + } +} diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 802b826..c35e41c 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -1,3 +1,4 @@ +import AVKit import Foundation import Observation import FlixyDownloads @@ -57,6 +58,21 @@ final class PlayerModel { /// Ob gerade auf eine andere Stufe umgestellt wird. private(set) var isChangingQuality = false + /// Die Bildebene des Systemplayers. + /// + /// Gehört dem Modell, nicht der Ansicht: das Modell überlebt den Wechsel + /// zwischen Kachel und Vollbild, die Ansicht nicht. Läge sie dort, entstünde + /// bei jedem Wechsel eine neue `AVPlayerLayer`, und das Bild setzte neu an. + let surface = PlayerLayerView() + /// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit. + /// + /// `@ObservationIgnored`, weil keine Ansicht darauf zeichnet — und weil + /// `@Observable` einen `lazy`-Delegaten nicht überlebt. + @ObservationIgnored private var pictureInPicture: AVPictureInPictureController? + @ObservationIgnored private var pictureInPictureDelegate: PictureInPictureBridge? + /// Was beim Beenden von Bild-in-Bild wieder ins Vollbild führen soll. + @ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)? + private let request: PlaybackRequest private let server: PlexServer? private let downloads: DownloadCoordinator @@ -280,6 +296,7 @@ final class PlayerModel { let engine = makeEngine(for: decision) self.engine = engine + attachSurface(to: engine) observe(engine) do { @@ -395,6 +412,9 @@ final class PlayerModel { engine?.teardown() engine = nil + pictureInPicture = nil + pictureInPictureDelegate = nil + surface.playerLayer.player = nil } // MARK: - Fortschritt melden @@ -473,6 +493,40 @@ final class PlayerModel { return item.media[request.mediaIndex] } + /// Hängt die gehaltene Bildebene an den aktuellen Motor. + /// + /// Nach **jedem** Motorwechsel nötig — also auch nach einem + /// Qualitätswechsel, der den Motor neu baut. Ohne das zeigt die Ebene + /// weiter auf einen abgeräumten Player und bleibt schwarz. + private func attachSurface(to engine: any VideoPlayerEngine) { + guard let avEngine = engine as? AVPlayerEngine else { return } + + surface.playerLayer.player = avEngine.player + surface.playerLayer.videoGravity = .resizeAspect + + guard AVPictureInPictureController.isPictureInPictureSupported() else { return } + if pictureInPicture == nil { + let bridge = PictureInPictureBridge { [weak self] in + self?.restoreFromPictureInPicture?() + } + let controller = AVPictureInPictureController(playerLayer: surface.playerLayer) + // Beim Verlassen der App ins Bild-in-Bild wechseln, statt die + // Wiedergabe anzuhalten. + controller?.canStartPictureInPictureAutomaticallyFromInline = true + controller?.delegate = bridge + pictureInPictureDelegate = bridge + pictureInPicture = controller + } + } + + /// Was passieren soll, wenn der Nutzer aus Bild-in-Bild zurückkehrt. + /// + /// Bisher gab es das nicht: Bild-in-Bild war eine Einbahnstraße, weil + /// niemand den Delegaten gesetzt hat. + func onPictureInPictureRestore(_ action: @escaping () -> Void) { + restoreFromPictureInPicture = action + } + private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine { switch decision.engine { case .vlc: VLCPlayerEngine() @@ -565,3 +619,26 @@ extension MediaTrack { return normalized == code.lowercased().prefix(2) } } + + +/// Verbindet Bild-in-Bild mit der App. +/// +/// Ein eigener Typ, weil `AVPictureInPictureControllerDelegate` von `NSObject` +/// erben muss und `PlayerModel` ein `@Observable` ist. Ohne einen Delegaten +/// war Bild-in-Bild eine Einbahnstraße: man kam hinein, aber nicht zurück in +/// den Player. +private final class PictureInPictureBridge: NSObject, AVPictureInPictureControllerDelegate { + private let restore: () -> Void + + init(restore: @escaping () -> Void) { + self.restore = restore + } + + func pictureInPictureController( + _ controller: AVPictureInPictureController, + restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completion: @escaping (Bool) -> Void + ) { + restore() + completion(true) + } +} diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 6af9fab..76cefc3 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -6,7 +6,13 @@ import PlexKit /// Der Vollbildplayer. struct PlayerView: View { - let request: PlaybackRequest + /// Das Modell gehört der Wurzel, nicht dieser Ansicht. + /// + /// Vorher wurde es hier gebaut und beim Verschwinden abgeräumt — und + /// genau daran starb die Wiedergabe, sobald man zurückging. Jetzt + /// überdauert es die Ansicht, und das Beenden ist eine Entscheidung des + /// Nutzers statt eine Nebenwirkung der Navigation. + let model: PlayerModel @Environment(FlixySession.self) private var session @Environment(PlaybackCoordinator.self) private var playback @@ -16,7 +22,6 @@ struct PlayerView: View { @Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(\.flixyAccent) private var accent - @State private var model: PlayerModel? @State private var showsControls = true @State private var showsTrackPicker = false @State private var showsQualityChoice = false @@ -27,8 +32,8 @@ struct PlayerView: View { ZStack { Color.black.ignoresSafeArea() - if let model { - VideoSurface(engine: model.engine) + Group { + VideoSurface(engine: model.engine, surface: model.surface) .ignoresSafeArea() switch model.phase { @@ -73,25 +78,23 @@ 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) + .animation(.easeInOut(duration: 0.2), value: model.isBuffering) .statusBarHidden(true) .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible() .contentShape(.rect) .onTapGesture { toggleControls() } .sheet(isPresented: $showsTrackPicker) { - if let model { TrackPickerSheet(model: model) } + TrackPickerSheet(model: model) } .confirmationDialog( Text("player.quality"), isPresented: $showsQualityChoice, titleVisibility: .visible ) { - if let model { - ForEach(PlayerQuality.allCases) { option in - Button(qualityChoiceLabel(option, current: model.quality)) { - Task { await model.select(quality: option) } - } + ForEach(PlayerQuality.allCases) { option in + Button(qualityChoiceLabel(option, current: model.quality)) { + Task { await model.select(quality: option) } } } Button("action.cancel", role: .cancel) {} @@ -104,29 +107,8 @@ struct PlayerView: View { withAnimation(.easeInOut(duration: 0.2)) { showsControls = true } scheduleControlsHide() } - .task { - let model = PlayerModel( - request: request, - server: session.server, - downloads: downloads, - diagnostics: diagnostics, - policy: settings.policy(for: network), - settings: settings, - isLocalConnection: session.server?.connection.local ?? false - ) - self.model = model - await model.start() - scheduleControlsHide() - } - .onDisappear { - hideTask?.cancel() - // Losgelöst von der Ansicht: das Anhalten des Transcoders und die - // Schlussmeldung sollen auch dann durchlaufen, wenn die Ansicht - // schon verschwunden ist. - if let model { - Task { await model.stop() } - } - } + .task { scheduleControlsHide() } + .onDisappear { hideTask?.cancel() } } // MARK: - Bedienelemente @@ -151,14 +133,27 @@ struct PlayerView: View { private func topBar(for model: PlayerModel) -> some View { HStack(alignment: .top, spacing: FlixySpacing.regular) { + // Der Pfeil verkleinert, er beendet nicht. Das ist der ganze + // Zweck der Übung: weiterschauen, während man stöbert. Button { - playback.dismiss() + playback.minimize() } label: { Image(systemName: "chevron.down") .font(.title3) .padding(FlixySpacing.snug) } - .accessibilityLabel(Text("player.close")) + .accessibilityLabel(Text("player.minimize")) + + // Und daneben der Weg, wirklich Schluss zu machen — sonst gäbe es + // keinen mehr, seit der Pfeil nur noch verkleinert. + Button { + playback.close() + } label: { + Image(systemName: "xmark") + .font(.callout.weight(.semibold)) + .padding(FlixySpacing.snug) + } + .accessibilityLabel(Text("player.stop")) VStack(alignment: .leading, spacing: 2) { Text(model.title) @@ -294,7 +289,7 @@ struct PlayerView: View { if model.hasReachedEnd, model.nextEpisodeRequest() != nil { Button { if let next = model.nextEpisodeRequest() { - playback.request = next + playback.play(next) } } label: { Label("player.nextEpisode", systemImage: "forward.end.fill") @@ -362,7 +357,7 @@ struct PlayerView: View { .font(.flixyBody) .foregroundStyle(.white) .multilineTextAlignment(.center) - Button("action.close") { playback.dismiss() } + Button("action.close") { playback.close() } .buttonStyle(.borderedProminent) .tint(accent) } @@ -397,7 +392,7 @@ struct PlayerView: View { hideTask?.cancel() hideTask = Task { try? await Task.sleep(for: .seconds(3.5)) - guard !Task.isCancelled, model?.isPlaying == true else { return } + guard !Task.isCancelled, model.isPlaying else { return } withAnimation(.easeInOut(duration: 0.3)) { showsControls = false } } } diff --git a/Flixy/Views/Player/VideoSurface.swift b/Flixy/Views/Player/VideoSurface.swift index 29a0126..db40200 100644 --- a/Flixy/Views/Player/VideoSurface.swift +++ b/Flixy/Views/Player/VideoSurface.swift @@ -11,11 +11,20 @@ import FlixyVLC /// `UIView`. struct VideoSurface: View { let engine: (any VideoPlayerEngine)? + /// Die Bildebene des Systemplayers, gehalten vom Modell. + /// + /// **Nicht** hier gebaut. Eine `AVPlayerLayer`, die in `makeUIView` + /// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau + /// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild + /// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild. + /// VLC macht es seit jeher richtig und reicht seine `videoView` durch; + /// hier ist es jetzt derselbe Weg. + let surface: PlayerLayerView var body: some View { switch engine { - case let avEngine as AVPlayerEngine: - AVPlayerSurface(player: avEngine.player) + case is AVPlayerEngine: + UIViewContainer(view: surface) case let vlcEngine as VLCPlayerEngine: UIViewContainer(view: vlcEngine.videoView) default: @@ -24,37 +33,6 @@ struct VideoSurface: View { } } -/// Die Bildebene des Systemplayers, samt Bild-in-Bild. -private struct AVPlayerSurface: UIViewRepresentable { - let player: AVPlayer - - func makeUIView(context: Context) -> PlayerLayerView { - let view = PlayerLayerView() - view.playerLayer.player = player - view.playerLayer.videoGravity = .resizeAspect - context.coordinator.setUp(with: view.playerLayer) - return view - } - - func updateUIView(_ uiView: PlayerLayerView, context: Context) { - uiView.playerLayer.player = player - } - - func makeCoordinator() -> Coordinator { Coordinator() } - - final class Coordinator { - private var pictureInPicture: AVPictureInPictureController? - - func setUp(with layer: AVPlayerLayer) { - guard AVPictureInPictureController.isPictureInPictureSupported() else { return } - pictureInPicture = AVPictureInPictureController(playerLayer: layer) - // Beim Verlassen der App ins Bild-in-Bild wechseln, statt die - // Wiedergabe anzuhalten. - pictureInPicture?.canStartPictureInPictureAutomaticallyFromInline = true - } - } -} - /// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist. /// /// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet, @@ -66,6 +44,9 @@ final class PlayerLayerView: UIView { } /// Hängt eine bestehende `UIView` in den SwiftUI-Baum. +/// +/// Umhängen statt neu bauen: dieselbe `UIView` wandert zwischen Kachel und +/// Vollbild hin und her, und die Wiedergabe merkt davon nichts. private struct UIViewContainer: UIViewRepresentable { let view: UIView diff --git a/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift b/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift new file mode 100644 index 0000000..f72ac77 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift @@ -0,0 +1,84 @@ +import Foundation +import CoreGraphics + +/// Wo die schwebende Videokachel sitzt. +/// +/// Eigener Typ statt Rechnungen in der Ansicht, aus demselben Grund wie bei +/// `GuideLayout`: hier geht lautlos schief, was man nicht sieht. Eine Kachel, +/// die halb aus dem Bild ragt, oder eine, die beim Loslassen in die falsche +/// Ecke springt, fällt erst auf dem Gerät auf — und dann als Gefühl, nicht +/// als Fehler. +public struct MiniPlayerPlacement: Sendable, Equatable { + public enum Corner: String, Sendable, CaseIterable, Codable { + case topLeading, topTrailing, bottomLeading, bottomTrailing + + var isTop: Bool { self == .topLeading || self == .topTrailing } + var isLeading: Bool { self == .topLeading || self == .bottomLeading } + } + + /// Der sichtbare Bereich, in dem die Kachel sich bewegen darf. + public let bounds: CGSize + public let tile: CGSize + /// Abstand zum Rand. Eine Kachel, die den Rand berührt, sieht aus wie ein + /// Fehler statt wie eine Entscheidung. + public let inset: CGFloat + + public init(bounds: CGSize, tile: CGSize, inset: CGFloat = FlixySpacing.regular) { + self.bounds = bounds + self.tile = tile + self.inset = inset + } + + /// Die Ecke, in die eine losgelassene Kachel gehört. + /// + /// Entschieden wird nach dem **Mittelpunkt** der Kachel, nicht nach dem + /// Finger: sonst springt eine Kachel, die man an der oberen Kante + /// angefasst hat, in die falsche Hälfte. + public func nearestCorner(toCenter center: CGPoint) -> Corner { + let isLeading = center.x < bounds.width / 2 + let isTop = center.y < bounds.height / 2 + + switch (isTop, isLeading) { + case (true, true): return .topLeading + case (true, false): return .topTrailing + case (false, true): return .bottomLeading + case (false, false): return .bottomTrailing + } + } + + /// Die linke obere Ecke der Kachel für eine Ecke des Bildschirms. + public func origin(for corner: Corner) -> CGPoint { + CGPoint( + x: corner.isLeading ? inset : max(inset, bounds.width - tile.width - inset), + y: corner.isTop ? inset : max(inset, bounds.height - tile.height - inset) + ) + } + + /// Der Mittelpunkt der Kachel in einer Ecke — damit rechnet SwiftUI. + public func center(for corner: Corner) -> CGPoint { + let start = origin(for: corner) + return CGPoint(x: start.x + tile.width / 2, y: start.y + tile.height / 2) + } + + /// Hält einen Mittelpunkt im sichtbaren Bereich. + /// + /// Während des Ziehens, damit die Kachel nicht hinter den Rand rutscht und + /// dort unerreichbar wird — der Finger wäre dann schneller als das Ziel. + public func clamped(center: CGPoint) -> CGPoint { + let halfWidth = tile.width / 2 + let halfHeight = tile.height / 2 + + let minX = inset + halfWidth + let maxX = bounds.width - inset - halfWidth + let minY = inset + halfHeight + let maxY = bounds.height - inset - halfHeight + + return CGPoint( + // `max(min…)` in dieser Reihenfolge, weil bei einem Bereich, der + // schmaler ist als die Kachel, sonst der untere Anschlag verliert + // und die Kachel rechts hinausragt. + x: max(minX, min(maxX, center.x)), + y: max(minY, min(maxY, center.y)) + ) + } +} diff --git a/Packages/FlixyKit/Tests/FlixyDesignTests/MiniPlayerPlacementTests.swift b/Packages/FlixyKit/Tests/FlixyDesignTests/MiniPlayerPlacementTests.swift new file mode 100644 index 0000000..d0bc5f5 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyDesignTests/MiniPlayerPlacementTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import FlixyDesign + +/// Wo die schwebende Videokachel landet. +@Suite("Kachel-Platzierung") +struct MiniPlayerPlacementTests { + + /// Ein iPhone im Hochformat, Kachel 180 × 101. + private let placement = MiniPlayerPlacement( + bounds: CGSize(width: 390, height: 844), + tile: CGSize(width: 180, height: 101), + inset: 12 + ) + + @Test("Jede Bildschirmhälfte führt in ihre Ecke") + func picksTheNearestCorner() { + #expect(placement.nearestCorner(toCenter: CGPoint(x: 50, y: 50)) == .topLeading) + #expect(placement.nearestCorner(toCenter: CGPoint(x: 340, y: 50)) == .topTrailing) + #expect(placement.nearestCorner(toCenter: CGPoint(x: 50, y: 800)) == .bottomLeading) + #expect(placement.nearestCorner(toCenter: CGPoint(x: 340, y: 800)) == .bottomTrailing) + } + + @Test("Genau auf der Mitte gewinnt unten rechts") + func decidesTheTie() { + // Willkürlich, aber festgelegt: ohne eine Regel für den Gleichstand + // hinge das Ergebnis an Fließkomma-Zufall, und dieselbe Geste führte + // mal hierhin, mal dorthin. + let center = CGPoint(x: 195, y: 422) + + #expect(placement.nearestCorner(toCenter: center) == .bottomTrailing) + } + + @Test("In jeder Ecke bleibt der Sicherheitsabstand") + func keepsTheInset() { + for corner in MiniPlayerPlacement.Corner.allCases { + let start = placement.origin(for: corner) + + #expect(start.x >= placement.inset) + #expect(start.y >= placement.inset) + #expect(start.x + placement.tile.width <= 390 - placement.inset) + #expect(start.y + placement.tile.height <= 844 - placement.inset) + } + } + + @Test("Unten rechts sitzt die Kachel auch wirklich unten rechts") + func computesTheFarCorner() { + let start = placement.origin(for: .bottomTrailing) + + #expect(start.x == 198.0) + #expect(start.y == 731.0) + } + + @Test("Beim Ziehen rutscht nichts über den Rand") + func clampsWhileDragging() { + // Ohne diese Grenze zieht man die Kachel hinter den Bildschirmrand, + // und dort ist sie mit dem Finger nicht mehr einzuholen. + let farOut = placement.clamped(center: CGPoint(x: -500, y: -500)) + #expect(farOut.x == 102.0) + #expect(farOut.y == 62.5) + + let alsoFarOut = placement.clamped(center: CGPoint(x: 9_000, y: 9_000)) + #expect(alsoFarOut.x == 288.0) + #expect(alsoFarOut.y == 781.5) + } + + @Test("Ein Punkt in der Mitte bleibt, wo er ist") + func leavesValidPointsAlone() { + let inside = CGPoint(x: 195, y: 400) + + #expect(placement.clamped(center: inside) == inside) + } + + @Test("Ein zu enger Bereich schiebt die Kachel nicht hinaus") + func survivesTinyBounds() { + // Kommt beim Drehen und in der Übergangsphase vor: der Bereich ist + // kurzzeitig schmaler als die Kachel. Ein falsch herum geklammerter + // Wert schöbe sie dann dauerhaft aus dem Bild. + let narrow = MiniPlayerPlacement( + bounds: CGSize(width: 100, height: 80), + tile: CGSize(width: 180, height: 101), + inset: 12 + ) + + #expect(narrow.origin(for: .bottomTrailing) == CGPoint(x: 12, y: 12)) + #expect(narrow.clamped(center: CGPoint(x: 0, y: 0)) == CGPoint(x: 102, y: 62.5)) + } +}