diff --git a/Flixy/Branding.swift b/Flixy/Branding.swift index 299711d..c34dd39 100644 --- a/Flixy/Branding.swift +++ b/Flixy/Branding.swift @@ -20,6 +20,14 @@ enum Branding { /// stehen, bis er von Hand entfernt wird. static let plexProduct = appName + /// Unter welchem Namen die Tokens im Schlüsselbund liegen. + /// + /// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere + /// Umbenennung der App soll niemanden abmelden. + static var keychainService: String { + Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flixy" + } + /// Die Version aus dem Bundle, für die Kopfzeile `X-Plex-Version`. static var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 19079d0..abc67af 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -1,22 +1,37 @@ import SwiftUI import FlixyDesign +import PlexKit @main struct FlixyApp: App { - /// Lebt hier, nicht in einer View: die Akzentfarbe gilt für die gesamte - /// App, und ein Neuaufbau der Wurzelansicht darf die Auswahl nicht - /// zurücksetzen. + /// Leben hier, nicht in einer View: sie gelten für die gesamte App, und + /// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch die + /// eingestellte Farbe zurücksetzen. @State private var theme = FlixyTheme() + @State private var session: FlixySession + @State private var playback = PlaybackCoordinator() + + init() { + let identity = PlexClientIdentity.current( + product: Branding.plexProduct, + version: Branding.version + ) + _session = State(initialValue: FlixySession( + identity: identity, + tokenStore: KeychainTokenStore(service: Branding.keychainService) + )) + } var body: some Scene { WindowGroup { RootView() + .environment(session) + .environment(playback) .environment(theme) .flixyAccent(theme) // Der eigentliche Schalter für „nur dunkel" steht in der // Info.plist (UIUserInterfaceStyle). Diese Zeile ist die - // Absicherung für SwiftUI-Vorschauen, die die Info.plist - // nicht lesen. + // Absicherung für SwiftUI-Vorschauen, die sie nicht lesen. .preferredColorScheme(.dark) .tint(theme.accentColor) } diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 08ff262..4b47a66 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -1,159 +1,1828 @@ { - "sourceLanguage": "en", - "strings": { - "sidebar.libraries": { - "comment": "Überschrift über der Liste der Mediatheken in der Seitenleiste", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mediatheken" + "sourceLanguage" : "en", + "strings" : { + "4K" : { + "comment" : "A label for a 4K video.", + "isCommentAutoGenerated" : true + }, + "720p" : { + "comment" : "A label for a 720p video.", + "isCommentAutoGenerated" : true + }, + "1080p" : { + "comment" : "A label for a 1080p video.", + "isCommentAutoGenerated" : true + }, + "action.apply" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Übernehmen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Libraries" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apply" } } } }, - "sidebar.more": { - "comment": "Überschrift über Live-TV, Downloads und Einstellungen", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mehr" + "action.cancel" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "More" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" } } } }, - "sidebar.liveTV": { - "comment": "Eintrag in der Seitenleiste", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Live-TV" + "action.close" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Live TV" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" } } } }, - "sidebar.downloads": { - "comment": "Eintrag in der Seitenleiste", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Downloads" + "action.retry" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut versuchen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Downloads" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try again" } } } }, - "sidebar.settings": { - "comment": "Eintrag in der Seitenleiste", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Einstellungen" + "action.signOut" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abmelden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign out" } } } }, - "sidebar.open": { - "comment": "Barrierefreiheits-Beschriftung des Hamburger-Knopfes", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Seitenleiste öffnen" + "connecting.searching" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server werden gesucht …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open sidebar" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Looking for servers…" } } } }, - "sidebar.close": { - "comment": "Barrierefreiheits-Beschriftung der abgedunkelten Fläche", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Seitenleiste schließen" + "connecting.to %@" : { + "comment" : "Platzhalter: Servername", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinde mit %@ …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Close sidebar" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecting to %@…" } } } }, - "placeholder.description": { - "comment": "Text auf den Platzhalter-Bildschirmen von Phase 0", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Dieser Bereich entsteht in einer späteren Phase." + "detail.cast" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Besetzung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "This area is coming in a later phase." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cast" } } } }, - "sidebar.home": { - "comment": "Eintrag der Startseite in der Seitenleiste", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Start" + "detail.episodeCount %lld" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Folgen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Home" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld episodes" + } + } + } + }, + "detail.episodes" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folgen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Episodes" + } + } + } + }, + "detail.playFromStart" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Von vorn abspielen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play from start" + } + } + } + }, + "detail.seasons" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Staffeln" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seasons" + } + } + } + }, + "detail.unwatchedCount %lld" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ungesehen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld unwatched" + } + } + } + }, + "detail.versions" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fassung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + } + } + }, + "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" : "S%lld E%lld" + } + } + } + }, + "error.noServers" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "An deinem Plex-Konto hängt kein Server." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There's no server on your Plex account." + } + } + } + }, + "error.notFound" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Titel ist auf dem Server nicht mehr vorhanden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This title is no longer on the 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" : "The server responded with error %lld." + } + } + } + }, + "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" : "The server can't be reached. Check that it's running and that you're on the same network." + } + } + } + }, + "error.title" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das hat nicht geklappt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Something went wrong" + } + } + } + }, + "error.unauthorized" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Anmeldung ist abgelaufen. Melde dich erneut an." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your sign-in has expired. Please sign in again." + } + } + } + }, + "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" : "The server's response was unexpected. It may be running a newer version of Plex." + } + } + } + }, + "filter.any" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Any" + } + } + } + }, + "filter.filters" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filters" + } + } + } + }, + "filter.genre" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genre" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genre" + } + } + } + }, + "filter.reset" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurücksetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset" + } + } + } + }, + "filter.resolution" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auflösung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resolution" + } + } + } + }, + "filter.sort" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sortierung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sort by" + } + } + } + }, + "filter.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filters" + } + } + } + }, + "filter.unwatchedOnly" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur Ungesehene" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unwatched only" + } + } + } + }, + "hero.details" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" + } + } + } + }, + "hero.play" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abspielen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play" + } + } + } + }, + "hero.resume" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weiterschauen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume" + } + } + } + }, + "home.empty.description" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobald dein Server Mediatheken enthält, erscheinen sie hier." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Once your server has libraries, they'll show up here." + } + } + } + }, + "home.empty.title" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch nichts da" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing here yet" + } + } + } + }, + "item.watched" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesehen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watched" + } + } + } + }, + "library.clearFilters" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter zurücksetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear filters" + } + } + } + }, + "library.empty.description" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit den aktuellen Filtern bleibt nichts übrig." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing matches the current filters." + } + } + } + }, + "library.empty.title" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Titel" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No titles" + } + } + } + }, + "library.filters" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filters" + } + } + } + }, + "library.toggleLayout" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ansicht wechseln" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Switch layout" + } + } + } + }, + "placeholder.description" : { + "comment" : "Platzhalter-Bildschirme", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Bereich entsteht in einer späteren Phase." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This area is coming in a later phase." + } + } + } + }, + "player.back10" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 Sekunden zurück" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Back 10 seconds" + } + } + } + }, + "player.close" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Player schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close player" + } + } + } + }, + "player.forward10" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 Sekunden vor" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forward 10 seconds" + } + } + } + }, + "player.loadFailed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Datei ließ sich nicht öffnen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The file couldn't be opened." + } + } + } + }, + "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." + } + } + } + }, + "player.pause" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + } + } + }, + "player.play" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abspielen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play" + } + } + } + }, + "player.playbackFailed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Wiedergabe wurde unterbrochen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playback was interrupted." + } + } + } + }, + "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" : "%lldh %lldm" + } + } + } + }, + "runtime.minutes %lld" : { + "comment" : "Laufzeit unter einer Stunde", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Min" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldm" + } + } + } + }, + "SD" : { + "comment" : "A label for a 720p movie.", + "isCommentAutoGenerated" : true + }, + "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." + } + } + } + }, + "search.recent" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt gesucht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recent searches" + } + } + } + }, + "search.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search" + } + } + } + }, + "serverPicker.owned" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eigener Server" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your server" + } + } + } + }, + "serverPicker.shared" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geteilt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shared" + } + } + } + }, + "serverPicker.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server wählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a server" + } + } + } + }, + "settings.address" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adresse" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Address" + } + } + } + }, + "settings.app" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App" + } + } + } + }, + "settings.connection" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbindung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection" + } + } + } + }, + "settings.connectionLocal" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lokal" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Local" + } + } + } + }, + "settings.connectionRelay" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über Plex-Vermittlung (gedrosselt)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via Plex relay (throttled)" + } + } + } + }, + "settings.connectionRemote" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkt von außen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direct remote" + } + } + } + }, + "settings.libraries" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediatheken" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Libraries" + } + } + } + }, + "settings.server" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server" + } + } + } + }, + "settings.serverName" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + } + } + }, + "settings.signOutConfirm" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wirklich abmelden?" + } + }, + "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." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The token will be removed from this device. Downloaded titles are kept." + } + } + } + }, + "settings.version" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "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" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close sidebar" + } + } + } + }, + "sidebar.downloads" : { + "comment" : "Eintrag in der Seitenleiste", + "extractionState" : "stale", + "localizations" : { + "de" : { + "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" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home" + } + } + } + }, + "sidebar.libraries" : { + "comment" : "Überschrift über der Liste der Mediatheken", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediatheken" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Libraries" + } + } + } + }, + "sidebar.liveTV" : { + "comment" : "Eintrag in der Seitenleiste", + "extractionState" : "stale", + "localizations" : { + "de" : { + "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" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "More" + } + } + } + }, + "sidebar.open" : { + "comment" : "Barrierefreiheit: Hamburger-Knopf", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seitenleiste öffnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open sidebar" + } + } + } + }, + "sidebar.settings" : { + "comment" : "Eintrag in der Seitenleiste", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "signIn.action" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit Plex anmelden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in with Plex" + } + } + } + }, + "signIn.codeHint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffne plex.tv/link im Browser und gib diesen Code ein:" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open plex.tv/link in your browser and enter this code:" + } + } + } + }, + "signIn.openBrowser" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Im Browser öffnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open in browser" + } + } + } + }, + "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." + } + } + } + }, + "signIn.waiting" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warte auf Bestätigung …" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for confirmation…" + } + } + } + }, + "sort.lastViewed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt gesehen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last watched" + } + } + } + }, + "sort.rating" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewertung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rating" + } + } + } + }, + "sort.recentlyAdded" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt hinzugefügt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recently added" + } + } + } + }, + "sort.releaseDate" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erscheinungsjahr" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Release date" + } + } + } + }, + "sort.titleAscending" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel (A–Z)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title (A–Z)" + } + } + } + }, + "sort.titleDescending" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel (Z–A)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title (Z–A)" + } + } + } + }, + "technical.audio" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tonspur" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio" + } + } + } + }, + "technical.bitrate" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitrate" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitrate" + } + } + } + }, + "technical.container" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Container" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Container" + } + } + } + }, + "technical.directPlayNative" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkt (Systemplayer)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direct (system player)" + } + } + } + }, + "technical.directPlayVLC" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkt (VLC)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direct (VLC)" + } + } + } + }, + "technical.file" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datei auf dem Server" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File on server" + } + } + } + }, + "technical.playbackMethod" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiedergabe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playback" + } + } + } + }, + "technical.reason" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grund" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reason" + } + } + } + }, + "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." + } + } + } + }, + "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." + } + } + } + }, + "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." + } + } + } + }, + "technical.reason.videoCodec %@" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS kann %@ nicht dekodieren." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS can't decode %@." + } + } + } + }, + "technical.resolution" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auflösung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resolution" + } + } + } + }, + "technical.size" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dateigröße" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File size" + } + } + } + }, + "technical.subtitle" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Untertitel" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subtitle" + } + } + } + }, + "technical.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Technische Angaben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Technical details" + } + } + } + }, + "technical.transcode" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umgerechnet vom Server" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcoded by server" + } + } + } + }, + "technical.videoCodec" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildcodec" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Video codec" } } } } }, - "version": "1.0" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift new file mode 100644 index 0000000..688d68b --- /dev/null +++ b/Flixy/Session/FlixySession.swift @@ -0,0 +1,215 @@ +import Foundation +import Observation +import PlexKit + +/// Der Zustand der Verbindung zum Server. +/// +/// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß, +/// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort, +/// an dem sich entscheidet, was auf dem Bildschirm erscheint. +@MainActor +@Observable +final class FlixySession { + enum Phase: Equatable { + /// Beim Start: liegt ein Token im Schlüsselbund? + case launching + case signedOut + /// Der Code steht bereit, der Nutzer bestätigt ihn im Browser. + case awaitingAuthorization(PlexPin) + /// Mehrere Server am Konto — einer muss gewählt werden. + case choosingServer([PlexResource]) + /// Ein Weg zum Server wird gesucht. + case connecting(serverName: String) + case ready + case failed(message: String) + + static func == (lhs: Phase, rhs: Phase) -> Bool { + switch (lhs, rhs) { + case (.launching, .launching), (.signedOut, .signedOut), (.ready, .ready): + true + case (.awaitingAuthorization(let l), .awaitingAuthorization(let r)): + l.id == r.id + case (.choosingServer(let l), .choosingServer(let r)): + l.map(\.clientIdentifier) == r.map(\.clientIdentifier) + case (.connecting(let l), .connecting(let r)): + l == r + case (.failed(let l), .failed(let r)): + l == r + default: + false + } + } + } + + private enum Key { + static let preferredServer = "flixy.server.clientIdentifier" + } + + /// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den + /// bestätigten Code darüber diesem Gerät zu. + var clientIdentifier: String { identity.clientIdentifier } + + private(set) var phase: Phase = .launching + private(set) var server: PlexServer? + /// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos + /// erscheinen gar nicht erst. + private(set) var libraries: [PlexLibrary] = [] + + private let identity: PlexClientIdentity + private let tokenStore: any TokenStoring + private let authenticator: PlexAuthenticator + private let defaults: UserDefaults + private var authorizationTask: Task? + + init( + identity: PlexClientIdentity, + tokenStore: any TokenStoring, + defaults: UserDefaults = .standard + ) { + self.identity = identity + self.tokenStore = tokenStore + self.defaults = defaults + self.authenticator = PlexAuthenticator(identity: identity, tokenStore: tokenStore) + } + + // MARK: - Start + + /// Versucht, mit einem bereits gespeicherten Token weiterzumachen. + func restore() async { + guard let token = authenticator.storedToken else { + phase = .signedOut + return + } + await connect(accountToken: token) + } + + // MARK: - Anmeldung + + func beginSignIn() async { + do { + let pin = try await authenticator.requestPin() + phase = .awaitingAuthorization(pin) + waitForAuthorization(of: pin) + } catch { + phase = .failed(message: message(for: error)) + } + } + + func cancelSignIn() { + authorizationTask?.cancel() + authorizationTask = nil + phase = .signedOut + } + + private func waitForAuthorization(of pin: PlexPin) { + authorizationTask?.cancel() + authorizationTask = Task { [authenticator] in + do { + let token = try await authenticator.waitForAuthorization(pin: pin) + guard !Task.isCancelled else { return } + await connect(accountToken: token) + } catch { + guard !Task.isCancelled else { return } + phase = .failed(message: message(for: error)) + } + } + } + + func signOut() { + authorizationTask?.cancel() + authenticator.signOut() + defaults.removeObject(forKey: Key.preferredServer) + server = nil + libraries = [] + phase = .signedOut + } + + // MARK: - Verbinden + + private func connect(accountToken: String) async { + phase = .connecting(serverName: "") + + let directory = PlexServerDirectory(identity: identity, token: accountToken) + let servers: [PlexResource] + do { + servers = try await directory.servers() + } catch PlexError.unauthorized { + // Der Token wurde am Konto zurückgezogen. Ihn zu behalten würde + // bei jedem Start denselben Fehler erzeugen. + authenticator.signOut() + phase = .signedOut + return + } catch { + phase = .failed(message: message(for: error)) + return + } + + guard !servers.isEmpty else { + phase = .failed(message: String(localized: "error.noServers")) + return + } + + // Beim zweiten Start soll niemand erneut wählen müssen. + let remembered = defaults.string(forKey: Key.preferredServer) + if let chosen = servers.first(where: { $0.clientIdentifier == remembered }) ?? servers.singleElement { + await connect(to: chosen) + } else { + phase = .choosingServer(servers) + } + } + + func connect(to resource: PlexResource) async { + phase = .connecting(serverName: resource.name) + + let selector = PlexConnectionSelector(identity: identity) + guard let connection = await selector.selectConnection(for: resource) else { + phase = .failed(message: String(localized: "error.serverUnreachable")) + return + } + + guard let server = PlexServer( + resource: resource, + connection: connection, + identity: identity + ) else { + phase = .failed(message: String(localized: "error.serverUnreachable")) + return + } + + self.server = server + defaults.set(resource.clientIdentifier, forKey: Key.preferredServer) + + do { + libraries = try await server.libraries().filter(\.isSupported) + phase = .ready + } catch { + phase = .failed(message: message(for: error)) + } + } + + /// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren. + func retry() async { + if let token = authenticator.storedToken { + await connect(accountToken: token) + } else { + phase = .signedOut + } + } + + private func message(for error: any Error) -> String { + guard let plexError = error as? PlexError else { return error.localizedDescription } + return switch plexError { + case .unauthorized: String(localized: "error.unauthorized") + case .notFound: String(localized: "error.notFound") + case .noReachableConnection: String(localized: "error.serverUnreachable") + case .server(let status): String(localized: "error.server \(status)") + case .transport(let description): description + case .decoding: String(localized: "error.unexpectedResponse") + } + } +} + +extension Array { + /// Das einzige Element — oder `nil`, wenn es keines oder mehrere gibt. + var singleElement: Element? { count == 1 ? first : nil } +} diff --git a/Flixy/Session/PlaybackCoordinator.swift b/Flixy/Session/PlaybackCoordinator.swift new file mode 100644 index 0000000..144409b --- /dev/null +++ b/Flixy/Session/PlaybackCoordinator.swift @@ -0,0 +1,76 @@ +import Foundation +import Observation +import PlexKit + +/// Was abgespielt werden soll. +struct PlaybackRequest: Identifiable, Hashable { + let id = UUID() + let ratingKey: String + /// Für die Titelzeile, solange die Metadaten noch geladen werden. + let title: String + /// Welche Fassung, wenn ein Titel in mehreren vorliegt. + let mediaIndex: Int + /// Ab wo — aus dem gemeldeten Fortschritt. + let startTime: Duration + + static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } +} + +/// Nimmt Abspielwünsche von überall entgegen. +/// +/// Ohne diese Stelle bräuchte jeder Bildschirm, von dem aus sich etwas starten +/// lässt — Aufmacher, Reihe, Detailseite, Suche — seinen eigenen +/// Vollbild-Übergang. Vier Kopien desselben Ablaufs, die auseinanderdriften. +@MainActor +@Observable +final class PlaybackCoordinator { + var request: PlaybackRequest? + + func play(_ item: PlexItem, mediaIndex: Int = 0, resuming: Bool = true) { + request = PlaybackRequest( + ratingKey: item.ratingKey, + title: item.playbackTitle, + mediaIndex: mediaIndex, + startTime: resuming ? item.resumeTime : .zero + ) + } + + func dismiss() { + request = nil + } +} + +extension PlexItem { + /// Wie der Titel im Player heißt. + /// + /// Bei Folgen ist der eigene Titel allein nutzlos — „Die erste Folge" + /// sagt nichts darüber, welche Serie läuft. + var playbackTitle: String { + if kind == .episode, let series = grandparentTitle { + return "\(series) · \(title)" + } + return title + } + + /// Ab welcher Stelle fortgesetzt wird. + var resumeTime: Duration { + guard let viewOffset, viewOffset > 0 else { return .zero } + return .milliseconds(viewOffset) + } + + /// Die Beschriftung unter einer Kachel. + var cardSubtitle: String? { + switch kind { + case .episode: + if let season = parentIndex, let episode = index { + return String(localized: "episode.short \(season) \(episode)") + } + return grandparentTitle + case .movie: + return year.map(String.init) + default: + return year.map(String.init) + } + } +} diff --git a/Flixy/Views/Detail/MediaDetailView.swift b/Flixy/Views/Detail/MediaDetailView.swift new file mode 100644 index 0000000..5bc52e1 --- /dev/null +++ b/Flixy/Views/Detail/MediaDetailView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Die Detailseite eines Titels. +/// +/// Holt die Metadaten frisch, statt den Eintrag aus der Liste weiterzureichen: +/// Einträge in Reihen und Rastern kommen ohne Dateiliste, ohne Besetzung und +/// ohne Marker. Für das Abspielen braucht es aber genau diese Angaben. +struct MediaDetailView: View { + let route: MediaRoute + + @Environment(FlixySession.self) private var session + @State private var state: LoadState = .loading + + var body: some View { + Group { + switch state { + case .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + + case .failed(let message): + ContentUnavailableView { + Label("error.title", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("action.retry") { Task { await load() } } + } + + case .loaded(let item): + switch item.kind { + case .show, .season: + SeriesDetailView(item: item) + default: + PlayableDetailView(item: item) + } + } + } + .background(Color.flixyCanvas) + .navigationTitle(state.value?.title ?? route.title) + .navigationBarTitleDisplayMode(.inline) + .task(id: route.ratingKey) { await load() } + } + + private func load() async { + guard let server = session.server else { return } + state = await LoadState.load { + try await server.metadata(ratingKey: route.ratingKey) + } + } +} diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift new file mode 100644 index 0000000..d5f87ea --- /dev/null +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -0,0 +1,205 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder +/// einzelne Folge. +struct PlayableDetailView: View { + let item: PlexItem + + @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback + @Environment(\.flixyAccent) private var accent + + @State private var selectedVersion = 0 + @State private var showsTechnicalDetails = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: FlixySpacing.section) { + header + + if item.hasMultipleVersions { + VersionPicker(media: item.media, selection: $selectedVersion) + .padding(.horizontal, FlixySpacing.screenMargin) + } + + if let summary = item.summary, !summary.isEmpty { + Text(summary) + .font(.flixyBody) + .foregroundStyle(Color.flixyTextSecondary) + .padding(.horizontal, FlixySpacing.screenMargin) + } + + if !item.genres.isEmpty { + TagStrip(tags: item.genres) + } + + if !item.roles.isEmpty { + CastRow(roles: item.roles) + } + + if let media = currentMedia, let part = media.parts.first { + TechnicalDetails( + media: media, + part: part, + isExpanded: $showsTechnicalDetails + ) + .padding(.horizontal, FlixySpacing.screenMargin) + } + } + .padding(.bottom, FlixySpacing.section) + } + .scrollIndicators(.hidden) + } + + private var currentMedia: PlexMedia? { + guard item.media.indices.contains(selectedVersion) else { return item.media.first } + return item.media[selectedVersion] + } + + private var header: some View { + HeroBanner( + title: item.rowTitle, + metadata: metadataLine, + summary: nil, + artworkURL: session.server?.artworkURL( + path: item.art ?? item.grandparentArt ?? item.thumb, + width: 900, + height: 1200 + ) + ) { + Button { + playback.play(item, mediaIndex: selectedVersion) + } label: { + Label( + item.watchProgress == nil ? "hero.play" : "hero.resume", + systemImage: "play.fill" + ) + .font(.flixyCardTitle) + .padding(.horizontal, FlixySpacing.loose) + .padding(.vertical, FlixySpacing.snug) + } + .buttonStyle(.borderedProminent) + .tint(accent) + .disabled(currentMedia?.parts.first == nil) + + if let progress = item.watchProgress, progress > 0 { + Button { + playback.play(item, mediaIndex: selectedVersion, resuming: false) + } label: { + Label("detail.playFromStart", systemImage: "gobackward") + .labelStyle(.iconOnly) + .font(.flixyCardTitle) + .padding(FlixySpacing.snug) + } + .buttonStyle(.bordered) + .tint(Color.flixyTextSecondary) + } + } + } + + private var metadataLine: String? { + var pieces: [String] = [] + if item.kind == .episode, let season = item.parentIndex, let episode = item.index { + pieces.append(String(localized: "episode.short \(season) \(episode)")) + } + if let year = item.year { pieces.append(String(year)) } + if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) } + if let rating = item.contentRating { pieces.append(rating) } + if let resolution = currentMedia?.videoResolution { pieces.append(resolution.uppercased()) } + return pieces.isEmpty ? nil : pieces.joined(separator: " · ") + } +} + +/// Auswahl zwischen mehreren Fassungen desselben Titels. +struct VersionPicker: View { + let media: [PlexMedia] + @Binding var selection: Int + + var body: some View { + VStack(alignment: .leading, spacing: FlixySpacing.snug) { + Text("detail.versions") + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + + Picker("detail.versions", selection: $selection) { + ForEach(Array(media.enumerated()), id: \.offset) { index, version in + Text(version.qualityLabel).tag(index) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + } +} + +/// Genres als Streifen. +struct TagStrip: View { + let tags: [PlexTag] + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: FlixySpacing.snug) { + ForEach(tags) { tag in + Text(tag.tag) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextSecondary) + .padding(.horizontal, FlixySpacing.regular) + .padding(.vertical, FlixySpacing.tight) + .background(Color.flixyElevated, in: .capsule) + } + } + .padding(.horizontal, FlixySpacing.screenMargin) + } + .scrollIndicators(.hidden) + } +} + +/// Die Besetzung mit Porträts. +struct CastRow: View { + let roles: [PlexTag] + @Environment(FlixySession.self) private var session + + var body: some View { + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + Text("detail.cast") + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + .padding(.horizontal, FlixySpacing.screenMargin) + + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: FlixySpacing.regular) { + ForEach(roles.prefix(20)) { role in + VStack(spacing: FlixySpacing.snug) { + FlixyArtwork( + url: session.server?.artworkURL(path: role.thumb, width: 160, height: 160), + aspectRatio: 1, + fallbackSymbol: "person" + ) + .frame(width: 72, height: 72) + .clipShape(.circle) + + Text(role.tag) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextPrimary) + .lineLimit(2) + .multilineTextAlignment(.center) + + if let character = role.role { + Text(character) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .lineLimit(1) + } + } + .frame(width: 90) + .accessibilityElement(children: .combine) + } + } + .padding(.horizontal, FlixySpacing.screenMargin) + } + .scrollIndicators(.hidden) + } + } +} diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift new file mode 100644 index 0000000..090c792 --- /dev/null +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -0,0 +1,178 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Die Detailseite einer Serie oder Staffel. +/// +/// Beide nutzen dieselbe Ansicht, weil sie sich nur im Inhalt der Liste +/// unterscheiden: eine Serie führt zu Staffeln, eine Staffel zu Folgen. +struct SeriesDetailView: View { + let item: PlexItem + + @Environment(FlixySession.self) private var session + @State private var children: LoadState<[PlexItem]> = .loading + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: FlixySpacing.section) { + header + + if let summary = item.summary, !summary.isEmpty { + Text(summary) + .font(.flixyBody) + .foregroundStyle(Color.flixyTextSecondary) + .padding(.horizontal, FlixySpacing.screenMargin) + } + + if !item.genres.isEmpty { TagStrip(tags: item.genres) } + + childList + + if !item.roles.isEmpty { CastRow(roles: item.roles) } + } + .padding(.bottom, FlixySpacing.section) + } + .scrollIndicators(.hidden) + .task(id: item.ratingKey) { await loadChildren() } + } + + private var header: some View { + HeroBanner( + title: item.title, + metadata: metadataLine, + summary: nil, + artworkURL: session.server?.artworkURL( + path: item.art ?? item.thumb, + width: 900, + height: 1200 + ) + ) { + EmptyView() + } + } + + @ViewBuilder + private var childList: some View { + switch children { + case .loading: + ProgressView().frame(maxWidth: .infinity).padding(.vertical, FlixySpacing.section) + + case .failed(let message): + Text(message) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .padding(.horizontal, FlixySpacing.screenMargin) + + case .loaded(let entries): + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + Text(item.kind == .show ? "detail.seasons" : "detail.episodes") + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + .padding(.horizontal, FlixySpacing.screenMargin) + + LazyVStack(spacing: 0) { + ForEach(entries) { entry in + NavigationLink(value: MediaRoute(ratingKey: entry.ratingKey, title: entry.title)) { + ChildRow(item: entry) + } + .buttonStyle(.plain) + + Divider().overlay(Color.flixySeparator) + } + } + } + } + } + + private var metadataLine: String? { + var pieces: [String] = [] + if let year = item.year { pieces.append(String(year)) } + if let leafCount = item.leafCount { + pieces.append(String(localized: "detail.episodeCount \(leafCount)")) + } + if let viewed = item.viewedLeafCount, let total = item.leafCount, viewed < total { + pieces.append(String(localized: "detail.unwatchedCount \(total - viewed)")) + } + if let rating = item.contentRating { pieces.append(rating) } + return pieces.isEmpty ? nil : pieces.joined(separator: " · ") + } + + private func loadChildren() async { + guard let server = session.server else { return } + children = await LoadState.load { + try await server.children(of: item.ratingKey) + } + } +} + +/// Eine Staffel- oder Folgenzeile. +private struct ChildRow: View { + let item: PlexItem + @Environment(FlixySession.self) private var session + + var body: some View { + HStack(spacing: FlixySpacing.regular) { + FlixyArtwork( + url: session.server?.artworkURL( + path: item.thumb, + width: item.kind == .season ? 120 : 240, + height: item.kind == .season ? 180 : 135 + ), + aspectRatio: item.kind == .season ? PosterCard.aspectRatio : LandscapeCard.aspectRatio, + fallbackSymbol: "play.rectangle" + ) + .frame(width: item.kind == .season ? 48 : 96) + .clipShape(.rect(cornerRadius: 4)) + .overlay(alignment: .bottom) { + if let progress = item.watchProgress { + WatchProgressBar(fraction: progress) + } + } + .clipShape(.rect(cornerRadius: 4)) + + VStack(alignment: .leading, spacing: 2) { + Text(rowTitle) + .font(.flixyCardTitle) + .foregroundStyle(Color.flixyTextPrimary) + .lineLimit(2) + + if let subtitle { + Text(subtitle) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .lineLimit(1) + } + } + + Spacer() + + if item.isWatched { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.flixyTextMuted) + .accessibilityLabel(Text("item.watched")) + } + } + .padding(.horizontal, FlixySpacing.screenMargin) + .padding(.vertical, FlixySpacing.snug) + .contentShape(.rect) + .accessibilityElement(children: .combine) + } + + private var rowTitle: String { + if item.kind == .episode, let episode = item.index { + return "\(episode). \(item.title)" + } + return item.title + } + + private var subtitle: String? { + switch item.kind { + case .season: + item.leafCount.map { String(localized: "detail.episodeCount \($0)") } + case .episode: + item.duration.map { Duration.milliseconds($0).runtimeLabel } + default: + nil + } + } +} diff --git a/Flixy/Views/Detail/TechnicalDetails.swift b/Flixy/Views/Detail/TechnicalDetails.swift new file mode 100644 index 0000000..fdbf8de --- /dev/null +++ b/Flixy/Views/Detail/TechnicalDetails.swift @@ -0,0 +1,128 @@ +import SwiftUI +import FlixyDesign +import FlixyPlayback +import PlexKit + +/// Die aufklappbaren technischen Angaben zu einer Datei. +/// +/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Flixy sie +/// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die +/// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding +/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird. +struct TechnicalDetails: View { + let media: PlexMedia + let part: PlexPart + @Binding var isExpanded: Bool + + private var decision: PlaybackDecision { + PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + } + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: FlixySpacing.snug) { + row("technical.playbackMethod", playbackMethodLabel) + if let reason = rationaleLabel { + row("technical.reason", reason) + } + + Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight) + + if let container = part.container { row("technical.container", container.uppercased()) } + if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) } + if let width = media.width, let height = media.height { + row("technical.resolution", "\(width) × \(height)") + } + if let bitrate = media.bitrate { + row("technical.bitrate", "\(bitrate / 1000) Mbit/s") + } + if let size = part.size { + row("technical.size", size.formatted(.byteCount(style: .file))) + } + + if !part.audioStreams.isEmpty { + Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight) + ForEach(part.audioStreams) { stream in + row("technical.audio", trackLabel(stream)) + } + } + + if !part.subtitleStreams.isEmpty { + ForEach(part.subtitleStreams) { stream in + row("technical.subtitle", trackLabel(stream)) + } + } + + if let file = part.file { + Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight) + VStack(alignment: .leading, spacing: 2) { + Text("technical.file") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + Text(file) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Color.flixyTextSecondary) + .textSelection(.enabled) + } + } + } + .padding(.top, FlixySpacing.snug) + } label: { + Text("technical.title") + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + } + .tint(Color.flixyTextSecondary) + } + + private func row(_ label: LocalizedStringKey, _ value: String) -> some View { + HStack(alignment: .top) { + Text(label) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + Spacer() + Text(value) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextSecondary) + .multilineTextAlignment(.trailing) + } + } + + private func trackLabel(_ stream: PlexStream) -> String { + stream.displayTitle ?? [stream.language, stream.codec?.uppercased()] + .compactMap(\.self) + .joined(separator: " · ") + } + + private var playbackMethodLabel: String { + switch decision.method { + case .directPlay(let engine): + switch engine { + case .avPlayer: String(localized: "technical.directPlayNative") + case .vlc: String(localized: "technical.directPlayVLC") + } + case .transcode: + String(localized: "technical.transcode") + } + } + + /// Der erste Grund, der gegen den Systemplayer sprach — mehrere + /// aufzuzählen hilft niemandem, es reicht einer. + private var rationaleLabel: String? { + for reason in decision.rationale { + switch reason { + case .containerUnsupported(let container): + return String(localized: "technical.reason.container \(container.uppercased())") + case .videoCodecUnsupported(let codec): + return String(localized: "technical.reason.videoCodec \(codec.uppercased())") + case .audioCodecUnsupported(let codec): + return String(localized: "technical.reason.audioCodec \(codec.uppercased())") + case .streamsUnknown: + return String(localized: "technical.reason.unanalyzed") + case .nativelySupported, .bitrateExceedsLimit, .transcodingUnavailable: + continue + } + } + return nil + } +} diff --git a/Flixy/Views/Home/HeroSection.swift b/Flixy/Views/Home/HeroSection.swift new file mode 100644 index 0000000..ed7a95a --- /dev/null +++ b/Flixy/Views/Home/HeroSection.swift @@ -0,0 +1,75 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Der Aufmacher oben auf der Startseite. +struct HeroSection: View { + let item: PlexItem + + @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback + @Environment(\.flixyAccent) private var accent + + var body: some View { + HeroBanner( + title: item.rowTitle, + metadata: metadata, + summary: item.summary, + artworkURL: session.server?.artworkURL( + path: item.art ?? item.grandparentArt ?? item.thumb, + width: 900, + height: 1200 + ) + ) { + Button { + playback.play(item) + } label: { + Label(playLabel, systemImage: "play.fill") + .font(.flixyCardTitle) + .padding(.horizontal, FlixySpacing.loose) + .padding(.vertical, FlixySpacing.snug) + } + .buttonStyle(.borderedProminent) + .tint(accent) + + NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { + Label("hero.details", systemImage: "info.circle") + .font(.flixyCardTitle) + .padding(.horizontal, FlixySpacing.loose) + .padding(.vertical, FlixySpacing.snug) + } + .buttonStyle(.bordered) + .tint(Color.flixyTextSecondary) + } + } + + /// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde — + /// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht. + private var playLabel: LocalizedStringKey { + item.watchProgress == nil ? "hero.play" : "hero.resume" + } + + private var metadata: String? { + var pieces: [String] = [] + if item.kind == .episode, let season = item.parentIndex, let episode = item.index { + pieces.append(String(localized: "episode.short \(season) \(episode)")) + } + if let year = item.year { pieces.append(String(year)) } + if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) } + if let rating = item.contentRating { pieces.append(rating) } + return pieces.isEmpty ? nil : pieces.joined(separator: " · ") + } +} + +extension Duration { + /// Laufzeit als „2 Std 46 Min" bzw. „46 Min". + var runtimeLabel: String { + let totalMinutes = Int(components.seconds / 60) + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + if hours > 0 { + return String(localized: "runtime.hoursMinutes \(hours) \(minutes)") + } + return String(localized: "runtime.minutes \(minutes)") + } +} diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift new file mode 100644 index 0000000..ff30e0c --- /dev/null +++ b/Flixy/Views/Home/HomeView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server. +struct HomeView: View { + @Environment(FlixySession.self) private var session + @State private var state: LoadState<[PlexHub]> = .loading + + var body: some View { + Group { + switch state { + case .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + + case .failed(let message): + ContentUnavailableView { + Label("error.title", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("action.retry") { Task { await load() } } + } + + case .loaded(let hubs): + if hubs.isEmpty { + ContentUnavailableView { + Label("home.empty.title", systemImage: "film.stack") + } description: { + Text("home.empty.description") + } + } else { + content(hubs: hubs) + } + } + } + .background(Color.flixyCanvas) + .navigationTitle(Branding.appName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { SearchToolbarItem() } + .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } + .task(id: session.server?.name) { await load() } + .refreshable { await load() } + } + + private func content(hubs: [PlexHub]) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { + if let featured = heroItem(in: hubs) { + HeroSection(item: featured) + } + + ForEach(hubs) { hub in + MediaRow(hub: hub) + } + } + .padding(.bottom, FlixySpacing.section) + } + .scrollIndicators(.hidden) + } + + /// Was oben groß gezeigt wird. + /// + /// Erste Wahl ist etwas Angefangenes — wer die App öffnet, will meistens + /// weiterschauen. Erst wenn es nichts Angefangenes gibt, tritt der + /// Neuzugang an die Stelle. + private func heroItem(in hubs: [PlexHub]) -> PlexItem? { + let continuing = hubs.first { $0.hubIdentifier.contains("continue") } + return continuing?.items.first ?? hubs.first?.items.first + } + + private func load() async { + guard let server = session.server else { return } + state = await LoadState.load(keepingPrevious: state) { + try await server.homeHubs() + } + } +} diff --git a/Flixy/Views/Home/MediaRow.swift b/Flixy/Views/Home/MediaRow.swift new file mode 100644 index 0000000..b3d66da --- /dev/null +++ b/Flixy/Views/Home/MediaRow.swift @@ -0,0 +1,73 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Eine waagerechte Reihe von Titeln. +struct MediaRow: View { + let hub: PlexHub + + @Environment(FlixySession.self) private var session + + /// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt, + /// dass es weitergeht, ohne einen Pfeil dafür zu brauchen. + private let cardWidth: CGFloat = 220 + + var body: some View { + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + Text(hub.title) + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + .padding(.horizontal, FlixySpacing.screenMargin) + + ScrollView(.horizontal) { + LazyHStack(spacing: FlixySpacing.regular) { + ForEach(hub.items) { item in + NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { + LandscapeCard( + title: item.rowTitle, + subtitle: item.cardSubtitle, + artworkURL: session.server?.artworkURL( + path: item.rowArtworkPath, + width: 440, + height: 248 + ), + progress: item.watchProgress + ) + .frame(width: cardWidth) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, FlixySpacing.screenMargin) + // Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe + // bleibt an beliebiger Stelle stehen. + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + .scrollIndicators(.hidden) + } + } +} + +extension PlexItem { + /// Der Titel auf einer Kachel in einer Reihe. + /// + /// Bei Folgen der Serientitel: in „Weiterschauen" stehen Folgen + /// verschiedener Serien nebeneinander, und der Folgentitel allein sagt + /// nicht, worum es geht. + var rowTitle: String { + kind == .episode ? (grandparentTitle ?? title) : title + } + + /// Welches Bild auf die Querformat-Kachel gehört. + /// + /// Bei Folgen ist `thumb` das Szenenbild der Folge — genau richtig. Bei + /// Filmen ist `thumb` das Hochkant-Plakat, das im Querformat beschnitten + /// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl. + var rowArtworkPath: String? { + switch kind { + case .episode: thumb ?? grandparentThumb + default: art ?? thumb + } + } +} diff --git a/Flixy/Views/Library/LibraryFilterSheet.swift b/Flixy/Views/Library/LibraryFilterSheet.swift new file mode 100644 index 0000000..71f46ff --- /dev/null +++ b/Flixy/Views/Library/LibraryFilterSheet.swift @@ -0,0 +1,104 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Filter und Sortierung einer Mediathek. +struct LibraryFilterSheet: View { + @Binding var query: LibraryQuery + let libraryKind: PlexLibrary.Kind? + + @Environment(\.dismiss) private var dismiss + @Environment(FlixySession.self) private var session + @State private var genres: [PlexFilterOption] = [] + + /// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird. + /// + /// Sonst löste jeder Tipp im Blatt sofort eine neue Serverabfrage aus — + /// bei drei Änderungen hintereinander drei verworfene Ladevorgänge. + @State private var draft: LibraryQuery + + init(query: Binding, libraryKind: PlexLibrary.Kind?) { + self._query = query + self.libraryKind = libraryKind + self._draft = State(initialValue: query.wrappedValue) + } + + var body: some View { + NavigationStack { + Form { + Section("filter.sort") { + Picker("filter.sort", selection: $draft.sort) { + ForEach(LibraryQuery.Sort.allCases, id: \.self) { sort in + Text(sort.label).tag(sort) + } + } + .labelsHidden() + .pickerStyle(.inline) + } + + Section("filter.filters") { + Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly) + + Picker("filter.genre", selection: $draft.genre) { + Text("filter.any").tag(String?.none) + ForEach(genres) { genre in + Text(genre.title).tag(String?.some(genre.key)) + } + } + + Picker("filter.resolution", selection: $draft.resolution) { + Text("filter.any").tag(String?.none) + Text("4K").tag(String?.some("4k")) + Text("1080p").tag(String?.some("1080")) + Text("720p").tag(String?.some("720")) + Text("SD").tag(String?.some("sd")) + } + } + + Section { + Button("filter.reset", role: .destructive) { + draft = LibraryQuery(sort: draft.sort) + } + .disabled(!draft.isFiltering) + } + } + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("filter.title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("action.cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("action.apply") { + query = draft + dismiss() + } + } + } + .task { await loadGenres() } + } + .presentationDetents([.medium, .large]) + } + + private func loadGenres() async { + guard let server = session.server, + let library = session.libraries.first(where: { $0.kind == libraryKind }) + else { return } + genres = (try? await server.filterOptions(inLibrary: library, field: "genre")) ?? [] + } +} + +extension LibraryQuery.Sort { + var label: LocalizedStringKey { + switch self { + case .titleAscending: "sort.titleAscending" + case .titleDescending: "sort.titleDescending" + case .recentlyAdded: "sort.recentlyAdded" + case .releaseDate: "sort.releaseDate" + case .rating: "sort.rating" + case .lastViewed: "sort.lastViewed" + } + } +} diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift new file mode 100644 index 0000000..664e489 --- /dev/null +++ b/Flixy/Views/Library/LibraryView.swift @@ -0,0 +1,242 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Eine Mediathek zum Durchblättern. +struct LibraryView: View { + let library: PlexLibrary + + @Environment(FlixySession.self) private var session + @AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid + + @State private var items: [PlexItem] = [] + @State private var state: LoadState = .loading + @State private var query = LibraryQuery() + @State private var page: PlexPage? + @State private var isLoadingMore = false + @State private var showsFilters = false + + var body: some View { + Group { + switch state { + case .loading where items.isEmpty: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + + case .failed(let message) where items.isEmpty: + ContentUnavailableView { + Label("error.title", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("action.retry") { Task { await reload() } } + } + + default: + if items.isEmpty { + ContentUnavailableView { + Label("library.empty.title", systemImage: "line.3.horizontal.decrease.circle") + } description: { + Text("library.empty.description") + } actions: { + if query.isFiltering { + Button("library.clearFilters") { + query = LibraryQuery(sort: query.sort) + } + } + } + } else { + contents + } + } + } + .background(Color.flixyCanvas) + .navigationTitle(library.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } + .sheet(isPresented: $showsFilters) { + LibraryFilterSheet(query: $query, libraryKind: library.kind) + } + .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } + .task(id: library.key) { await reload() } + // Jede Änderung an Filter oder Sortierung verwirft die bisherige + // Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei + // verschiedenen Abfragen vermischen. + .onChange(of: query) { _, _ in Task { await reload() } } + .refreshable { await reload() } + } + + @ViewBuilder + private var contents: some View { + switch layout { + case .grid: grid + case .list: list + } + } + + private var grid: some View { + ScrollView { + LazyVGrid( + // Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht + // ergibt auf dem iPhone drei und auf dem iPad sechs Spalten, + // ohne dass die Größenklasse abgefragt werden muss. + columns: [GridItem(.adaptive(minimum: 110), spacing: FlixySpacing.regular)], + spacing: FlixySpacing.loose + ) { + ForEach(items) { item in + NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { + PosterCard( + title: item.title, + subtitle: item.cardSubtitle, + artworkURL: session.server?.artworkURL(path: item.thumb, width: 300, height: 450), + progress: item.watchProgress + ) + } + .buttonStyle(.plain) + .task { await loadMoreIfNeeded(reaching: item) } + } + } + .padding(FlixySpacing.screenMargin) + + if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) } + } + } + + private var list: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(items) { item in + NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { + LibraryListRow(item: item) + } + .buttonStyle(.plain) + .task { await loadMoreIfNeeded(reaching: item) } + + Divider().overlay(Color.flixySeparator) + } + } + + if isLoadingMore { ProgressView().padding(.vertical, FlixySpacing.section) } + } + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + showsFilters = true + } label: { + Image(systemName: query.isFiltering + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + .accessibilityLabel(Text("library.filters")) + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + layout = layout == .grid ? .list : .grid + } label: { + Image(systemName: layout == .grid ? "list.bullet" : "square.grid.2x2") + } + .accessibilityLabel(Text("library.toggleLayout")) + } + + SearchToolbarItem() + } + + // MARK: - Laden + + private func reload() async { + guard let server = session.server else { return } + state = .loading + + do { + let first = try await server.items(inLibrary: library, query: query) + items = first.items + page = first + state = .loaded(()) + } catch is CancellationError { + return + } catch { + state = .failed(message: PlexErrorText.describe(error)) + } + } + + /// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert. + private func loadMoreIfNeeded(reaching item: PlexItem) async { + guard let server = session.server, + let page, page.hasMore, + !isLoadingMore, + // Fünf Einträge vor Schluss: früh genug, dass der Nachschub da + // ist, bevor jemand am Ende ankommt, und spät genug, dass nicht + // jedes Scrollen eine Anfrage auslöst. + items.suffix(5).contains(where: { $0.ratingKey == item.ratingKey }) + else { return } + + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let next = try await server.items( + inLibrary: library, + query: query, + offset: page.nextOffset + ) + // Plex kann bei gleichzeitigem Serverscan denselben Titel auf zwei + // Seiten liefern. Ohne diese Prüfung stünde er doppelt im Raster — + // und SwiftUI beschwert sich über doppelte Kennungen. + let known = Set(items.map(\.ratingKey)) + items.append(contentsOf: next.items.filter { !known.contains($0.ratingKey) }) + self.page = next + } catch { + // Stillschweigend: die Liste steht bereits, ein Fehlerblatt mitten + // im Scrollen wäre störender als ein paar fehlende Einträge. + self.page = nil + } + } +} + +enum LibraryLayout: String, CaseIterable { + case grid, list +} + +/// Eine Zeile in der Listenansicht. +private struct LibraryListRow: View { + let item: PlexItem + @Environment(FlixySession.self) private var session + + var body: some View { + HStack(spacing: FlixySpacing.regular) { + FlixyArtwork( + url: session.server?.artworkURL(path: item.thumb, width: 120, height: 180), + aspectRatio: PosterCard.aspectRatio + ) + .frame(width: 44) + .clipShape(.rect(cornerRadius: 4)) + + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.flixyCardTitle) + .foregroundStyle(Color.flixyTextPrimary) + .lineLimit(1) + if let subtitle = item.cardSubtitle { + Text(subtitle) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + } + } + + Spacer() + + if item.isWatched { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.flixyTextMuted) + .accessibilityLabel(Text("item.watched")) + } + } + .padding(.horizontal, FlixySpacing.screenMargin) + .padding(.vertical, FlixySpacing.snug) + .contentShape(.rect) + .accessibilityElement(children: .combine) + } +} diff --git a/Flixy/Views/LoadState.swift b/Flixy/Views/LoadState.swift new file mode 100644 index 0000000..db280f4 --- /dev/null +++ b/Flixy/Views/LoadState.swift @@ -0,0 +1,73 @@ +import Foundation +import PlexKit + +/// Der Ladezustand eines Bildschirms. +/// +/// Ein gemeinsamer Typ statt dreier Einzelfelder (`isLoading`, `items`, +/// `errorMessage`): die lassen sich widersprüchlich besetzen — lädt noch und +/// hat gleichzeitig einen Fehler — und jede View müsste selbst entscheiden, +/// was dann gilt. +enum LoadState: Sendable { + case loading + case loaded(Value) + case failed(message: String) + + var value: Value? { + if case .loaded(let value) = self { return value } + return nil + } +} + +extension LoadState { + /// Führt eine Ladeoperation aus und übersetzt Fehler in Text. + /// + /// `keepingPrevious` ist für „Nach unten ziehen zum Aktualisieren": dabei + /// soll die Liste stehen bleiben, statt für die Dauer der Anfrage durch + /// einen Ladekreis ersetzt zu werden. + /// + /// Auf dem Hauptakteur, weil sie ausschließlich aus Views heraus gerufen + /// wird. Ohne diese Festlegung müsste der übergebene Ausdruck `Sendable` + /// sein — was er nie ist, weil er den Server aus der Umgebung mitnimmt. + @MainActor + static func load( + keepingPrevious previous: Self? = nil, + _ operation: () async throws -> Value + ) async -> Self { + do { + return .loaded(try await operation()) + } catch is CancellationError { + // Der Bildschirm wurde verlassen, während geladen wurde. Ein + // Fehlertext wäre hier falsch — es ist niemand mehr da, den er + // interessiert. + return previous ?? .loading + } catch { + return .failed(message: PlexErrorText.describe(error)) + } + } +} + +/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann. +enum PlexErrorText { + static func describe(_ error: any Error) -> String { + guard let plexError = error as? PlexError else { return error.localizedDescription } + return switch plexError { + case .unauthorized: String(localized: "error.unauthorized") + case .notFound: String(localized: "error.notFound") + case .noReachableConnection: String(localized: "error.serverUnreachable") + case .server(let status): String(localized: "error.server \(status)") + case .transport(let description): description + case .decoding: String(localized: "error.unexpectedResponse") + } + } +} + +/// Wohin ein Tipp auf eine Kachel führt. +/// +/// Nur der Schlüssel, nicht der ganze Titel: die Einträge aus den Reihen der +/// Startseite sind unvollständig — ohne Dateiliste, ohne Besetzung. Die +/// Detailseite muss den Titel ohnehin frisch holen. +struct MediaRoute: Hashable { + let ratingKey: String + /// Für die Titelzeile, solange noch geladen wird. + let title: String +} diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 968f22d..32c3ee4 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -1,49 +1,103 @@ import SwiftUI import FlixyDesign +import PlexKit /// Die Wurzelansicht. /// -/// Legt fest, in welcher Form die Seitenleiste erscheint: auf dem iPhone als -/// ausfahrbares Blatt, auf dem iPad dauerhaft neben dem Inhalt. Beide Wege -/// nutzen dieselbe `SidebarView` — der Unterschied liegt nur im Rahmen. +/// Entscheidet zuerst anhand des Sitzungszustands, ob überhaupt etwas zu +/// zeigen ist — und erst danach, in welcher Form die Seitenleiste erscheint: +/// auf dem iPhone als ausfahrbares Blatt, auf dem iPad dauerhaft neben dem +/// Inhalt. Beide Wege nutzen dieselbe `SidebarView`. struct RootView: View { + @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback @Environment(\.horizontalSizeClass) private var sizeClass @State private var selection: SidebarDestination = .home @State private var isSidebarOpen = false - /// Kommt in Phase 1 vom Server. Solange leer, blendet die Seitenleiste den - /// Abschnitt aus, statt eine leere Überschrift zu zeigen. - @State private var libraries: [SidebarDestination] = [] var body: some View { Group { - if sizeClass == .regular { - splitLayout - } else { - drawerLayout + switch session.phase { + case .launching: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.flixyCanvas) + + case .signedOut, .awaitingAuthorization: + SignInView( + phase: session.phase, + clientIdentifier: session.clientIdentifier, + onSignIn: { Task { await session.beginSignIn() } }, + onCancel: { session.cancelSignIn() } + ) + + case .choosingServer(let servers): + NavigationStack { + ServerPickerView(servers: servers) { resource in + Task { await session.connect(to: resource) } + } + } + + case .connecting(let name): + ConnectingView(serverName: name) + + case .failed(let message): + ConnectionFailureView( + message: message, + onRetry: { Task { await session.retry() } }, + onSignOut: { session.signOut() } + ) + + case .ready: + library } } .background(Color.flixyCanvas) + // 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) + } + .task { await session.restore() } + .onChange(of: session.phase) { _, newPhase in + // Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt + // bleiben, die es auf dem neuen Server gar nicht gibt. + if newPhase == .ready, case .library = selection { selection = .home } + } } - /// iPad: die Seitenleiste ist Teil des Fensters und lässt sich über den - /// Systemknopf einklappen. + @ViewBuilder + private var library: some View { + if sizeClass == .regular { + splitLayout + } else { + drawerLayout + } + } + + private var sidebarDestinations: [SidebarDestination] { + session.libraries.map { .library(key: $0.key, title: $0.title) } + } + + /// iPad: die Seitenleiste ist Teil des Fensters. private var splitLayout: some View { NavigationSplitView { - SidebarView(libraries: libraries, selection: $selection, showsHeader: false) {} - .navigationTitle(Branding.appName) - .toolbar(removing: .sidebarToggle) + SidebarView( + libraries: sidebarDestinations, + selection: $selection, + showsHeader: false + ) {} + .navigationTitle(Branding.appName) } detail: { - NavigationStack { - destination - } + NavigationStack { destination } } } /// iPhone: die Seitenleiste fährt über den Inhalt. private var drawerLayout: some View { DrawerContainer(isOpen: $isSidebarOpen) { - SidebarView(libraries: libraries, selection: $selection) { + SidebarView(libraries: sidebarDestinations, selection: $selection) { isSidebarOpen = false } } content: { @@ -67,15 +121,19 @@ struct RootView: View { private var destination: some View { switch selection { case .home: - PlaceholderScreen(destination: .home) + HomeView() case .library(let key, let title): - PlaceholderScreen(destination: .library(key: key, title: title)) + if let library = session.libraries.first(where: { $0.key == key }) { + LibraryView(library: library) + } else { + PlaceholderScreen(destination: .library(key: key, title: title)) + } case .liveTV: PlaceholderScreen(destination: .liveTV) case .downloads: PlaceholderScreen(destination: .downloads) case .settings: - PlaceholderScreen(destination: .settings) + SettingsView() } } } diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift new file mode 100644 index 0000000..23951b9 --- /dev/null +++ b/Flixy/Views/Player/PlayerModel.swift @@ -0,0 +1,160 @@ +import Foundation +import Observation +import FlixyPlayback +import FlixyVLC +import PlexKit + +/// Führt einen Abspielwunsch aus. +/// +/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den +/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die +/// Ansicht selbst weiß nicht, welcher Motor läuft — bis auf die eine Stelle, +/// an der die Bildfläche gebaut wird. +@MainActor +@Observable +final class PlayerModel { + enum Phase: Equatable { + case preparing + case playing + case failed(message: String) + } + + private(set) var phase: Phase = .preparing + private(set) var engine: (any VideoPlayerEngine)? + private(set) var decision: PlaybackDecision? + private(set) var title: String + private(set) var subtitle: String? + + private(set) var currentTime: Duration = .zero + private(set) var duration: Duration? + private(set) var isPlaying = false + + private let request: PlaybackRequest + private let server: PlexServer? + private var eventTask: Task? + + init(request: PlaybackRequest, server: PlexServer?) { + self.request = request + self.server = server + self.title = request.title + } + + func start() async { + guard let server else { + phase = .failed(message: String(localized: "error.serverUnreachable")) + return + } + + let item: PlexItem + do { + item = try await server.metadata(ratingKey: request.ratingKey) + } catch { + phase = .failed(message: PlexErrorText.describe(error)) + return + } + + title = item.rowTitle + subtitle = playerSubtitle(for: item) + + guard let media = version(of: item), + let part = media.parts.first, + let url = server.directPlayURL(part: part) + else { + phase = .failed(message: String(localized: "player.noPlayableFile")) + return + } + + // Phase 1 kennt nur DirectPlay. Sobald der Transcoder steht, wird hier + // eine andere Richtlinie übergeben — sonst ändert sich nichts. + let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + self.decision = decision + + let engine = makeEngine(for: decision) + self.engine = engine + observe(engine) + + do { + try await engine.load(PlaybackSource( + url: url, + origin: .remote, + startTime: request.startTime + )) + engine.play() + phase = .playing + } catch { + phase = .failed(message: String(localized: "player.loadFailed")) + } + } + + func togglePlayPause() { + guard let engine else { return } + if isPlaying { engine.pause() } else { engine.play() } + } + + func seek(to time: Duration) { + guard let engine else { return } + Task { await engine.seek(to: time) } + } + + /// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe. + func skip(by offset: Duration) { + guard let duration else { return } + let target = currentTime + offset + seek(to: max(.zero, min(target, duration))) + } + + func stop() { + eventTask?.cancel() + engine?.teardown() + engine = nil + } + + // MARK: - Innereien + + private func version(of item: PlexItem) -> PlexMedia? { + guard item.media.indices.contains(request.mediaIndex) else { return item.preferredMedia } + return item.media[request.mediaIndex] + } + + private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine { + switch decision.engine { + case .vlc: VLCPlayerEngine() + // Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS. + case .avPlayer, .none: AVPlayerEngine() + } + } + + private func observe(_ engine: any VideoPlayerEngine) { + eventTask?.cancel() + eventTask = Task { [weak self] in + for await event in engine.events { + guard let self else { return } + switch event { + case .stateChanged(let state): + isPlaying = state == .playing + if case .failed = state { + phase = .failed(message: String(localized: "player.playbackFailed")) + } + case .timeChanged(let time): + currentTime = time + case .durationResolved(let value): + duration = value + case .reachedEnd: + isPlaying = false + case .tracksResolved: + // Spurwahl kommt in Phase 2. Die Motoren melden sie jetzt + // schon — die Ansicht bietet sie nur noch nicht an. + break + } + } + } + } + + private func playerSubtitle(for item: PlexItem) -> String? { + guard item.kind == .episode else { return nil } + if let season = item.parentIndex, let episode = item.index { + return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)" + } + return item.title + } +} diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift new file mode 100644 index 0000000..1240d5c --- /dev/null +++ b/Flixy/Views/Player/PlayerView.swift @@ -0,0 +1,251 @@ +import SwiftUI +import FlixyDesign +import FlixyPlayback +import PlexKit + +/// Der Vollbildplayer. +struct PlayerView: View { + let request: PlaybackRequest + + @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback + @Environment(\.flixyAccent) private var accent + + @State private var model: PlayerModel? + @State private var showsControls = true + @State private var hideTask: Task? + @State private var scrubbing: Double? + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + if let model { + VideoSurface(engine: model.engine) + .ignoresSafeArea() + + switch model.phase { + case .preparing: + ProgressView().tint(.white) + + case .failed(let message): + failure(message: message) + + case .playing: + if showsControls { + controls(for: model) + .transition(.opacity) + } + } + } + } + .statusBarHidden(showsControls == false) + // Die Wiedergabe soll den Bildschirm wachhalten — bei einem Film ohne + // Berührung schaltet das Gerät sonst nach zwei Minuten ab. + .persistentSystemOverlays(showsControls ? .automatic : .hidden) + .contentShape(.rect) + .onTapGesture { toggleControls() } + .task { + let model = PlayerModel(request: request, server: session.server) + self.model = model + await model.start() + scheduleControlsHide() + } + .onDisappear { + hideTask?.cancel() + model?.stop() + } + } + + // MARK: - Bedienelemente + + private func controls(for model: PlayerModel) -> some View { + VStack { + topBar(for: model) + Spacer() + transport(for: model) + Spacer() + scrubber(for: model) + } + .padding(FlixySpacing.loose) + .background { + // Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen + // Szenen lesbar bleibt, wenig genug, dass das Bild nicht verfälscht + // wirkt. + Color.black.opacity(0.35).ignoresSafeArea() + } + } + + private func topBar(for model: PlayerModel) -> some View { + HStack(alignment: .top) { + Button { + playback.dismiss() + } label: { + Image(systemName: "chevron.down") + .font(.title3) + .padding(FlixySpacing.snug) + } + .accessibilityLabel(Text("player.close")) + + VStack(alignment: .leading, spacing: 2) { + Text(model.title) + .font(.flixyCardTitle) + .lineLimit(1) + if let subtitle = model.subtitle { + Text(subtitle) + .font(.flixyCaption) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + } + } + + Spacer() + + if let decision = model.decision { + PlaybackMethodBadge(decision: decision) + } + } + .foregroundStyle(.white) + } + + private func transport(for model: PlayerModel) -> some View { + HStack(spacing: FlixySpacing.section * 2) { + Button { + model.skip(by: .seconds(-10)) + scheduleControlsHide() + } label: { + Image(systemName: "gobackward.10").font(.largeTitle) + } + .accessibilityLabel(Text("player.back10")) + + Button { + model.togglePlayPause() + scheduleControlsHide() + } label: { + Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 52)) + } + .accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play")) + + Button { + model.skip(by: .seconds(10)) + scheduleControlsHide() + } label: { + Image(systemName: "goforward.10").font(.largeTitle) + } + .accessibilityLabel(Text("player.forward10")) + } + .foregroundStyle(.white) + } + + private func scrubber(for model: PlayerModel) -> some View { + VStack(spacing: FlixySpacing.tight) { + Slider( + value: Binding( + get: { scrubbing ?? model.currentTime.seconds }, + set: { scrubbing = $0 } + ), + in: 0...max(model.duration?.seconds ?? 1, 1), + onEditingChanged: { editing in + // Erst beim Loslassen springen: bei jedem Zwischenwert zu + // springen lässt das Bild flackern und hält bei großen + // Dateien den Puffer dauerhaft leer. + if !editing, let target = scrubbing { + model.seek(to: .seconds(target)) + scrubbing = nil + } + scheduleControlsHide() + } + ) + .tint(accent) + + HStack { + Text((scrubbing.map(Duration.seconds) ?? model.currentTime).timecode) + Spacer() + if let duration = model.duration { + Text("−" + (duration - (scrubbing.map(Duration.seconds) ?? model.currentTime)).timecode) + } + } + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.white.opacity(0.8)) + } + } + + private func failure(message: String) -> some View { + VStack(spacing: FlixySpacing.loose) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.white.opacity(0.8)) + Text(message) + .font(.flixyBody) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + Button("action.close") { playback.dismiss() } + .buttonStyle(.borderedProminent) + .tint(accent) + } + .padding(FlixySpacing.section) + } + + // MARK: - Ein- und Ausblenden + + private func toggleControls() { + withAnimation(.easeInOut(duration: 0.2)) { showsControls.toggle() } + if showsControls { scheduleControlsHide() } + } + + /// Blendet die Bedienelemente nach kurzer Ruhe aus — aber nur, solange + /// tatsächlich etwas läuft. Im pausierten Zustand wäre ein leerer + /// Bildschirm ohne jede Bedienmöglichkeit das Ergebnis. + private func scheduleControlsHide() { + hideTask?.cancel() + hideTask = Task { + try? await Task.sleep(for: .seconds(3.5)) + guard !Task.isCancelled, model?.isPlaying == true else { return } + withAnimation(.easeInOut(duration: 0.3)) { showsControls = false } + } + } +} + +/// Zeigt an, auf welchem Weg gerade abgespielt wird. +/// +/// In Phase 1 vor allem eine Hilfe beim Prüfen: an ihr lässt sich ablesen, ob +/// eine Datei wirklich direkt läuft oder ob die Entscheidung danebenlag. +struct PlaybackMethodBadge: View { + let decision: PlaybackDecision + + var body: some View { + Text(label) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.white.opacity(0.75)) + .padding(.horizontal, FlixySpacing.snug) + .padding(.vertical, FlixySpacing.tight) + .background(.white.opacity(0.15), in: .capsule) + } + + private var label: String { + switch decision.method { + case .directPlay(let engine): + switch engine { + case .avPlayer: "AVPlayer" + case .vlc: "VLC" + } + case .transcode: + "Transcode" + } + } +} + +extension Duration { + /// Als „1:23:45" bzw. „23:45". + var timecode: String { + let total = max(0, Int(components.seconds)) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + let seconds = total % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%d:%02d", minutes, seconds) + } +} diff --git a/Flixy/Views/Player/VideoSurface.swift b/Flixy/Views/Player/VideoSurface.swift new file mode 100644 index 0000000..29a0126 --- /dev/null +++ b/Flixy/Views/Player/VideoSurface.swift @@ -0,0 +1,74 @@ +import AVKit +import SwiftUI +import FlixyPlayback +import FlixyVLC + +/// Die Bildfläche des laufenden Motors. +/// +/// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden +/// Motoren sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise +/// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche +/// `UIView`. +struct VideoSurface: View { + let engine: (any VideoPlayerEngine)? + + var body: some View { + switch engine { + case let avEngine as AVPlayerEngine: + AVPlayerSurface(player: avEngine.player) + case let vlcEngine as VLCPlayerEngine: + UIViewContainer(view: vlcEngine.videoView) + default: + Color.black + } + } +} + +/// 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, +/// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das +/// UIKit. +final class PlayerLayerView: UIView { + override static var layerClass: AnyClass { AVPlayerLayer.self } + var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } +} + +/// Hängt eine bestehende `UIView` in den SwiftUI-Baum. +private struct UIViewContainer: UIViewRepresentable { + let view: UIView + + func makeUIView(context: Context) -> UIView { view } + func updateUIView(_ uiView: UIView, context: Context) {} +} diff --git a/Flixy/Views/Search/SearchView.swift b/Flixy/Views/Search/SearchView.swift new file mode 100644 index 0000000..d662236 --- /dev/null +++ b/Flixy/Views/Search/SearchView.swift @@ -0,0 +1,133 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Der Knopf, der zur Suche führt. Steht in mehreren Werkzeugleisten. +struct SearchToolbarItem: ToolbarContent { + var body: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: SearchRoute()) { + Image(systemName: "magnifyingglass") + } + .accessibilityLabel(Text("search.title")) + } + } +} + +struct SearchRoute: Hashable {} + +/// Sucht über alle Mediatheken des Servers. +struct SearchView: View { + @Environment(FlixySession.self) private var session + + @State private var text = "" + @State private var hubs: [PlexHub] = [] + @State private var isSearching = false + @State private var recent: [String] = UserDefaults.standard.stringArray(forKey: recentKey) ?? [] + + private static let recentKey = "flixy.search.recent" + private static let recentLimit = 8 + + var body: some View { + Group { + if text.isEmpty { + recentSearches + } else if hubs.isEmpty && !isSearching { + ContentUnavailableView.search(text: text) + } else { + results + } + } + .background(Color.flixyCanvas) + .navigationTitle(Text("search.title")) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always)) + .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } + // Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer + // kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los, + // von denen elf verworfen werden. + .task(id: text) { await search() } + } + + private var results: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { + ForEach(hubs) { hub in + MediaRow(hub: hub) + } + } + .padding(.vertical, FlixySpacing.loose) + } + } + + @ViewBuilder + private var recentSearches: some View { + if recent.isEmpty { + ContentUnavailableView { + Label("search.title", systemImage: "magnifyingglass") + } description: { + Text("search.hint") + } + } else { + List { + Section { + ForEach(recent, id: \.self) { entry in + Button { + text = entry + } label: { + Label(entry, systemImage: "clock.arrow.circlepath") + .foregroundStyle(Color.flixyTextSecondary) + } + } + .onDelete { offsets in + recent.remove(atOffsets: offsets) + persistRecent() + } + } header: { + Text("search.recent") + } + .listRowBackground(Color.flixySurface) + } + .scrollContentBackground(.hidden) + } + } + + private func search() async { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let server = session.server, trimmed.count >= 2 else { + hubs = [] + return + } + + do { + try await Task.sleep(for: .milliseconds(300)) + } catch { + // Ein neuer Tastendruck hat diese Aufgabe abgebrochen — genau der + // Zweck der Pause. + return + } + + isSearching = true + defer { isSearching = false } + + do { + hubs = try await server.search(trimmed) + if !hubs.isEmpty { remember(trimmed) } + } catch is CancellationError { + return + } catch { + hubs = [] + } + } + + private func remember(_ entry: String) { + recent.removeAll { $0.caseInsensitiveCompare(entry) == .orderedSame } + recent.insert(entry, at: 0) + recent = Array(recent.prefix(Self.recentLimit)) + persistRecent() + } + + private func persistRecent() { + UserDefaults.standard.set(recent, forKey: Self.recentKey) + } +} diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..7f5d53c --- /dev/null +++ b/Flixy/Views/Settings/SettingsView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Die Einstellungen. +/// +/// In Phase 1 bewusst schmal: Serverangaben und Abmelden. Akzentfarbe, +/// Qualitätsregler und Download-Einstellungen kommen mit den Phasen, in denen +/// es das Geregelte auch gibt. +struct SettingsView: View { + @Environment(FlixySession.self) private var session + @State private var showsSignOutConfirmation = false + + var body: some View { + Form { + if let server = session.server { + Section("settings.server") { + LabeledContent("settings.serverName", value: server.name) + LabeledContent("settings.connection", value: connectionLabel(server.connection)) + LabeledContent("settings.address", value: server.connection.uri) + .font(.flixyCaption) + .textSelection(.enabled) + } + + Section("settings.libraries") { + ForEach(session.libraries) { library in + LabeledContent(library.title, value: library.type.capitalized) + } + } + } + + Section("settings.app") { + LabeledContent("settings.version", value: Branding.version) + } + + Section { + Button("action.signOut", role: .destructive) { + showsSignOutConfirmation = true + } + } + } + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("sidebar.settings")) + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog( + Text("settings.signOutConfirm"), + isPresented: $showsSignOutConfirmation, + titleVisibility: .visible + ) { + Button("action.signOut", role: .destructive) { session.signOut() } + Button("action.cancel", role: .cancel) {} + } message: { + Text("settings.signOutExplanation") + } + } + + /// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach + /// Ruckeln ist das die erste Frage. + private func connectionLabel(_ connection: PlexConnection) -> String { + if connection.local { return String(localized: "settings.connectionLocal") } + if connection.relay { return String(localized: "settings.connectionRelay") } + return String(localized: "settings.connectionRemote") + } +} diff --git a/Flixy/Views/SignIn/ConnectionStateViews.swift b/Flixy/Views/SignIn/ConnectionStateViews.swift new file mode 100644 index 0000000..f012d4c --- /dev/null +++ b/Flixy/Views/SignIn/ConnectionStateViews.swift @@ -0,0 +1,93 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Während ein Weg zum Server gesucht wird. +struct ConnectingView: View { + let serverName: String + + var body: some View { + VStack(spacing: FlixySpacing.loose) { + ProgressView() + Text(serverName.isEmpty + ? String(localized: "connecting.searching") + : String(localized: "connecting.to \(serverName)")) + .font(.flixyBody) + .foregroundStyle(Color.flixyTextSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.flixyCanvas) + } +} + +/// Wenn am Konto mehrere Server hängen. +struct ServerPickerView: View { + let servers: [PlexResource] + let onSelect: (PlexResource) -> Void + + @Environment(\.flixyAccent) private var accent + + var body: some View { + List(servers) { server in + Button { + onSelect(server) + } label: { + HStack(spacing: FlixySpacing.regular) { + Image(systemName: server.owned ? "server.rack" : "person.2") + .foregroundStyle(accent) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(server.name) + .font(.flixyCardTitle) + .foregroundStyle(Color.flixyTextPrimary) + Text(subtitle(for: server)) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + } + + Spacer() + Image(systemName: "chevron.right") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .listRowBackground(Color.flixySurface) + } + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("serverPicker.title")) + } + + private func subtitle(for server: PlexResource) -> String { + var pieces: [String] = [] + pieces.append(server.owned + ? String(localized: "serverPicker.owned") + : String(localized: "serverPicker.shared")) + if let platform = server.platform { pieces.append(platform) } + if let version = server.productVersion { pieces.append(version) } + return pieces.joined(separator: " · ") + } +} + +/// Wenn etwas schiefgegangen ist. +struct ConnectionFailureView: View { + let message: String + let onRetry: () -> Void + let onSignOut: () -> Void + + var body: some View { + ContentUnavailableView { + Label("error.title", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("action.retry", action: onRetry).buttonStyle(.borderedProminent) + Button("action.signOut", action: onSignOut) + .foregroundStyle(Color.flixyTextSecondary) + } + .background(Color.flixyCanvas) + } +} diff --git a/Flixy/Views/SignIn/SignInView.swift b/Flixy/Views/SignIn/SignInView.swift new file mode 100644 index 0000000..2137957 --- /dev/null +++ b/Flixy/Views/SignIn/SignInView.swift @@ -0,0 +1,107 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Der Anmeldebildschirm. +/// +/// Zeigt den Code, den Plex vergeben hat, und schickt in den Browser. Die App +/// bekommt Benutzername und Passwort nie zu sehen — sie fragt nur regelmäßig +/// nach, ob der Code bestätigt wurde. +struct SignInView: View { + let phase: FlixySession.Phase + let clientIdentifier: String + let onSignIn: () -> Void + let onCancel: () -> Void + + @Environment(\.openURL) private var openURL + @Environment(\.flixyAccent) private var accent + + var body: some View { + VStack(spacing: FlixySpacing.section) { + Spacer() + + VStack(spacing: FlixySpacing.regular) { + Image(systemName: "play.rectangle.on.rectangle") + .font(.system(size: 52)) + .foregroundStyle(accent) + + Text(Branding.appName) + .font(.flixyHero) + .foregroundStyle(Color.flixyTextPrimary) + + Text("signIn.tagline") + .font(.flixyBody) + .foregroundStyle(Color.flixyTextSecondary) + .multilineTextAlignment(.center) + } + + Spacer() + + if case .awaitingAuthorization(let pin) = phase { + awaitingAuthorization(pin: pin) + } else { + Button(action: onSignIn) { + Text("signIn.action") + .font(.flixyRowHeader) + .frame(maxWidth: .infinity) + .padding(.vertical, FlixySpacing.regular) + } + .buttonStyle(.borderedProminent) + .tint(accent) + } + + Spacer().frame(height: FlixySpacing.section) + } + .padding(.horizontal, FlixySpacing.section) + .frame(maxWidth: 420) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.flixyCanvas) + } + + @ViewBuilder + private func awaitingAuthorization(pin: PlexPin) -> some View { + VStack(spacing: FlixySpacing.loose) { + Text("signIn.codeHint") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextSecondary) + .multilineTextAlignment(.center) + + Text(pin.code) + // Feste Zeichenbreite, damit der Code beim Abtippen nicht + // springt und Ziffern nicht mit Buchstaben verschwimmen. + .font(.system(.largeTitle, design: .monospaced, weight: .semibold)) + .tracking(6) + .foregroundStyle(Color.flixyTextPrimary) + .textSelection(.enabled) + .padding(.vertical, FlixySpacing.regular) + .frame(maxWidth: .infinity) + .background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.sheet)) + + Button { + if let url = pin.authorizationURL( + clientIdentifier: clientIdentifier, + product: Branding.plexProduct + ) { + openURL(url) + } + } label: { + Label("signIn.openBrowser", systemImage: "safari") + .frame(maxWidth: .infinity) + .padding(.vertical, FlixySpacing.regular) + } + .buttonStyle(.borderedProminent) + .tint(accent) + + HStack(spacing: FlixySpacing.snug) { + ProgressView().controlSize(.small) + Text("signIn.waiting") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + } + + Button("action.cancel", action: onCancel) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextSecondary) + } + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Localizable.xcstrings b/Packages/FlixyKit/Sources/FlixyDesign/Localizable.xcstrings index a83d748..46cfd2b 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Localizable.xcstrings +++ b/Packages/FlixyKit/Sources/FlixyDesign/Localizable.xcstrings @@ -1,17 +1,66 @@ { "sourceLanguage" : "en", "strings" : { + "Amber" : { + "comment" : "Name of the accent color option.", + "isCommentAutoGenerated" : true + }, + "Custom" : { + "comment" : "Name of the accent color option when the user has selected a custom color.", + "isCommentAutoGenerated" : true + }, + "Ember" : { + "comment" : "Name of the accent color \"Ember\".", + "isCommentAutoGenerated" : true + }, + "Indigo" : { + "comment" : "Name of the accent color option \"Indigo\".", + "isCommentAutoGenerated" : true + }, + "Lime" : { + "comment" : "Name of the accent color \"Lime\".", + "isCommentAutoGenerated" : true + }, + "Mint" : { + "comment" : "Name of the accent color \"Mint\".", + "isCommentAutoGenerated" : true + }, + "Orchid" : { + "comment" : "Name of the accent color \"Orchid\".", + "isCommentAutoGenerated" : true + }, "progress.watched" : { "comment" : "Barrierefreiheits-Beschriftung des Fortschrittsbalkens unter angefangenen Titeln", "localizations" : { "de" : { - "stringUnit" : { "state" : "translated", "value" : "Fortschritt" } + "stringUnit" : { + "state" : "translated", + "value" : "Fortschritt" + } }, "en" : { - "stringUnit" : { "state" : "translated", "value" : "Progress" } + "stringUnit" : { + "state" : "translated", + "value" : "Progress" + } } } + }, + "Rose" : { + "comment" : "Text for a color name.", + "isCommentAutoGenerated" : true + }, + "Sky" : { + + }, + "Tangerine" : { + "comment" : "Name of the accent color option \"Tangerine\".", + "isCommentAutoGenerated" : true + }, + "Teal" : { + "comment" : "Name of the accent color teal.", + "isCommentAutoGenerated" : true } }, - "version" : "1.0" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift new file mode 100644 index 0000000..8fbf18a --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -0,0 +1,261 @@ +import AVFoundation +import Foundation + +/// Wiedergabe über den Systemplayer. +/// +/// Der bevorzugte Weg, wann immer das Format ihn zulässt: Bild-in-Bild, +/// AirPlay, die Steuerung im Sperrbildschirm und der Umgang mit +/// Unterbrechungen durch Anrufe kommen alle mit, ohne dass etwas davon +/// nachgebaut werden müsste. +@MainActor +public final class AVPlayerEngine: VideoPlayerEngine { + /// Wird von der Ansicht gebraucht, um die Bildebene zu bauen. Der einzige + /// Grund, warum AVFoundation überhaupt nach außen sichtbar ist. + public let player = AVPlayer() + + public private(set) var state: PlaybackState = .idle { + didSet { + guard state != oldValue else { return } + continuation?.yield(.stateChanged(state)) + } + } + + public private(set) var duration: Duration? + public private(set) var audioTracks: [MediaTrack] = [] + public private(set) var subtitleTracks: [MediaTrack] = [] + + public var currentTime: Duration { + let seconds = player.currentTime().seconds + guard seconds.isFinite, seconds >= 0 else { return .zero } + return .seconds(seconds) + } + + public let supportsPictureInPicture = true + + public private(set) lazy var events: AsyncStream = { + AsyncStream { continuation in + self.continuation = continuation + } + }() + + private var continuation: AsyncStream.Continuation? + private var timeObserver: Any? + private var observations: [NSKeyValueObservation] = [] + private var endObserver: (any NSObjectProtocol)? + + public init() { + AudioSession.activate() + } + + public func load(_ source: PlaybackSource) async throws(PlaybackFailure) { + teardownObservers() + state = .loading + + let asset = AVURLAsset(url: source.url) + let item = AVPlayerItem(asset: asset) + player.replaceCurrentItem(with: item) + + observe(item: item) + observePlayer() + + do { + let assetDuration = try await asset.load(.duration) + if assetDuration.isNumeric { + let value = Duration.seconds(assetDuration.seconds) + duration = value + continuation?.yield(.durationResolved(value)) + } + try await loadTracks(from: asset) + } catch { + let failure = PlaybackFailure( + kind: .sourceUnavailable, + underlyingDescription: String(describing: error) + ) + state = .failed(failure) + throw failure + } + + if source.startTime > .zero { + await seek(to: source.startTime) + } + } + + public func play() { + player.play() + } + + public func pause() { + player.pause() + } + + public func seek(to time: Duration) async { + let target = CMTime(seconds: time.seconds, preferredTimescale: 600) + // Genau springen statt zum nächsten Schlüsselbild: beim Zurückspringen + // um zehn Sekunden landet man sonst je nach Datei bis zu mehreren + // Sekunden daneben. + await player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero) + continuation?.yield(.timeChanged(time)) + } + + public func setRate(_ rate: Double) { + player.rate = Float(rate) + } + + public func selectAudioTrack(_ track: MediaTrack?) { + select(track, in: .audible) + } + + public func selectSubtitleTrack(_ track: MediaTrack?) { + select(track, in: .legible) + } + + public func teardown() { + teardownObservers() + player.replaceCurrentItem(with: nil) + continuation?.finish() + continuation = nil + state = .idle + AudioSession.deactivate() + } + + // MARK: - Spurwahl + + private func select(_ track: MediaTrack?, in characteristic: AVMediaCharacteristic) { + guard let item = player.currentItem, + let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: characteristic) + else { return } + + guard let track, let index = track.engineIdentifier, group.options.indices.contains(index) else { + // `nil` schaltet ab — bei Untertiteln der Normalfall, bei Ton + // sollte es nicht vorkommen. + item.select(nil, in: group) + return + } + item.select(group.options[index], in: group) + } + + private func loadTracks(from asset: AVURLAsset) async throws { + func tracks( + for characteristic: AVMediaCharacteristic, + kind: MediaTrack.Kind + ) async throws -> [MediaTrack] { + guard let group = try await asset.loadMediaSelectionGroup(for: characteristic) else { return [] } + return group.options.enumerated().map { index, option in + MediaTrack( + id: "av-\(kind)-\(index)", + kind: kind, + displayName: option.displayName, + languageCode: option.extendedLanguageTag, + engineIdentifier: index, + isForced: option.hasMediaCharacteristic(.containsOnlyForcedSubtitles) + ) + } + } + + audioTracks = try await tracks(for: .audible, kind: .audio) + subtitleTracks = try await tracks(for: .legible, kind: .subtitle) + continuation?.yield(.tracksResolved(audio: audioTracks, subtitle: subtitleTracks)) + } + + // MARK: - Beobachtung + + private func observePlayer() { + observations.append( + player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in + MainActor.assumeIsolated { + guard let self else { return } + switch player.timeControlStatus { + case .playing: self.state = .playing + case .paused: if self.state != .ended { self.state = .paused } + case .waitingToPlayAtSpecifiedRate: self.state = .buffering + @unknown default: break + } + } + } + ) + + // Viermal je Sekunde: fein genug, dass der Schieberegler flüssig + // wirkt, grob genug, dass er nicht jede Bildwiederholung antreibt. + timeObserver = player.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), + queue: .main + ) { [weak self] time in + MainActor.assumeIsolated { + guard let self, time.isNumeric else { return } + self.continuation?.yield(.timeChanged(.seconds(time.seconds))) + } + } + } + + private func observe(item: AVPlayerItem) { + observations.append( + item.observe(\.status, options: [.new]) { [weak self] item, _ in + MainActor.assumeIsolated { + guard let self, item.status == .failed else { return } + self.state = .failed(PlaybackFailure( + kind: .sourceUnavailable, + underlyingDescription: item.error.map { String(describing: $0) } + )) + } + } + ) + + endObserver = NotificationCenter.default.addObserver( + forName: AVPlayerItem.didPlayToEndTimeNotification, + object: item, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.state = .ended + self.continuation?.yield(.reachedEnd) + } + } + } + + private func teardownObservers() { + observations.forEach { $0.invalidate() } + observations.removeAll() + + if let timeObserver { + player.removeTimeObserver(timeObserver) + self.timeObserver = nil + } + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + self.endObserver = nil + } + } +} + +extension Duration { + /// Die Dauer als Gleitkommazahl in Sekunden — AVFoundation rechnet darin. + public var seconds: Double { + Double(components.seconds) + Double(components.attoseconds) / 1e18 + } +} + +/// Die Tonsitzung des Systems. +/// +/// Nur auf iOS vorhanden. Gekapselt statt an drei Stellen mit `#if` versehen — +/// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne +/// Simulator laufen. +enum AudioSession { + static func activate() { + #if os(iOS) + // Ohne diese Kategorie schweigt die Wiedergabe, sobald der + // Stummschalter am Gerät umgelegt ist — und Bild-in-Bild gäbe es gar + // nicht. + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try? AVAudioSession.sharedInstance().setActive(true) + #endif + } + + static func deactivate() { + #if os(iOS) + // `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen, + // die vor dem Film gespielt hat. + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + #endif + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift b/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift new file mode 100644 index 0000000..e11b494 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Führt die Anmeldung bei plex.tv durch. +public actor PlexAuthenticator { + /// Wo das Konto-Token im Schlüsselbund steht. + public static let accountTokenAccount = "plex.account" + + private let client: PlexHTTPClient + private let identity: PlexClientIdentity + private let tokenStore: any TokenStoring + private let clock: any Clock + + public init( + identity: PlexClientIdentity, + tokenStore: any TokenStoring, + transport: any PlexTransport = URLSession.shared, + clock: any Clock = ContinuousClock() + ) { + self.identity = identity + self.tokenStore = tokenStore + self.clock = clock + self.client = PlexHTTPClient( + baseURL: URL(string: "https://plex.tv")!, + identity: identity, + transport: transport + ) + } + + /// Der bereits gespeicherte Token, falls vorhanden. + public nonisolated var storedToken: String? { + tokenStore.token(for: Self.accountTokenAccount) + } + + /// Fordert einen neuen Anmeldecode an. + /// + /// Bewusst ohne `strong=true`: das liefert zwar einen schwerer zu + /// erratenden Code, aber einen dreißigstelligen. Bei plex.tv/link + /// abzutippen ist der nicht — und genau das ist der Weg, den Flixy + /// anbietet. Der kurze Code ist vier Zeichen lang und läuft nach wenigen + /// Minuten ab, was das Raten ausreichend aussichtslos macht. + public func requestPin() async throws(PlexError) -> PlexPin { + try await client.send( + PlexRequest(method: .post, path: "/api/v2/pins"), + as: PlexPin.self + ) + } + + /// Wartet darauf, dass der Nutzer den Code im Browser bestätigt. + /// + /// Plex bietet keinen Rückkanal — es bleibt nur regelmäßiges Nachfragen. + /// Das Intervall ist bewusst großzügig: der Weg über den Browser dauert + /// ohnehin Sekunden, und sekündliches Anklopfen brächte nichts außer Last. + public func waitForAuthorization( + pin: PlexPin, + pollInterval: Duration = .seconds(2), + timeout: Duration = .seconds(300) + ) async throws(PlexError) -> String { + // Über die Anzahl der Versuche statt über einen Zeitpunkt: eine + // existenzielle `any Clock` gibt ihren Instant-Typ nicht preis, und + // deshalb lassen sich zwei ihrer Zeitpunkte nicht vergleichen. Das + // Ergebnis ist dasselbe, und in Tests reicht eine Uhr, die sofort + // zurückkehrt. + // + // Nach oben begrenzt, damit ein versehentlich winziges Intervall nicht + // in einen Überlauf oder Millionen Anfragen läuft. + let attempts = pollInterval > .zero + ? min(1000, max(1, Int(timeout / pollInterval))) + : 1 + + for _ in 0.. URL? { + var components = URLComponents(string: "https://app.plex.tv/auth") + components?.fragment = "?clientID=\(clientIdentifier)&code=\(code)" + + "&context%5Bdevice%5D%5Bproduct%5D=\(product.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? product)" + return components?.url + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Auth/TokenStore.swift b/Packages/FlixyKit/Sources/PlexKit/Auth/TokenStore.swift new file mode 100644 index 0000000..3908891 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Auth/TokenStore.swift @@ -0,0 +1,100 @@ +import Foundation +import Security + +/// Legt Tokens im Schlüsselbund ab. +/// +/// Nicht in `UserDefaults`: ein Plex-Token gibt vollen Zugriff auf die +/// Mediathek und bleibt gültig, bis er am Konto zurückgezogen wird. In den +/// Voreinstellungen läge er als Klartext in einer Datei, die jedes Backup +/// mitnimmt. +public protocol TokenStoring: Sendable { + func store(_ token: String, for account: String) throws + func token(for account: String) -> String? + func remove(for account: String) +} + +public struct KeychainTokenStore: TokenStoring { + private let service: String + + public init(service: String) { + self.service = service + } + + public enum Failure: Error, Equatable { + case keychain(status: OSStatus) + } + + public func store(_ token: String, for account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + + let attributes: [String: Any] = [ + kSecValueData as String: Data(token.utf8), + // Ohne Gerätesperre kein Zugriff, und nicht in Backups übertragbar. + // Ein Token, der auf einem anderen Gerät wieder auftaucht, wäre + // ein stiller Zweitzugang zur Mediathek. + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + switch status { + case errSecSuccess: + return + case errSecItemNotFound: + let insert = query.merging(attributes) { current, _ in current } + let addStatus = SecItemAdd(insert as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw Failure.keychain(status: addStatus) } + default: + throw Failure.keychain(status: status) + } + } + + public func token(for account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + + return String(data: data, encoding: .utf8) + } + + public func remove(for account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + } +} + +/// Ein Ablageort im Arbeitsspeicher — für Tests und SwiftUI-Vorschauen. +public final class InMemoryTokenStore: TokenStoring, @unchecked Sendable { + private let lock = NSLock() + private var tokens: [String: String] = [:] + + public init() {} + + public func store(_ token: String, for account: String) throws { + lock.withLock { tokens[account] = token } + } + + public func token(for account: String) -> String? { + lock.withLock { tokens[account] } + } + + public func remove(for account: String) { + _ = lock.withLock { tokens.removeValue(forKey: account) } + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexHTTPClient.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexHTTPClient.swift new file mode 100644 index 0000000..72b2df5 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Client/PlexHTTPClient.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Führt Anfragen gegen einen Host aus. +/// +/// Es gibt zwei davon: einen für plex.tv (Anmeldung, Serverliste) und einen je +/// Server. Sie unterscheiden sich nur in Basisadresse und Token — deshalb +/// dieselbe Klasse statt zweier ähnlicher. +public actor PlexHTTPClient { + private let baseURL: URL + private let identity: PlexClientIdentity + private let transport: any PlexTransport + private var token: String? + + public init( + baseURL: URL, + identity: PlexClientIdentity, + token: String? = nil, + transport: any PlexTransport = URLSession.shared + ) { + self.baseURL = baseURL + self.identity = identity + self.token = token + self.transport = transport + } + + public func setToken(_ token: String?) { + self.token = token + } + + public var currentToken: String? { token } + + public func send(_ request: PlexRequest) async throws(PlexError) -> Data { + let urlRequest = try request.urlRequest(baseURL: baseURL, identity: identity, token: token) + + let data: Data + let response: URLResponse + do { + (data, response) = try await transport.data(for: urlRequest) + } catch let error as URLError { + // Der Klartext von URLError nennt Zeitüberschreitung, fehlende + // Verbindung oder Zertifikatsprobleme beim Namen. Das ist der + // Unterschied zwischen einer brauchbaren Fehlermeldung und + // „Etwas ist schiefgelaufen". + throw .transport(description: error.localizedDescription) + } catch { + throw .transport(description: String(describing: error)) + } + + return try PlexResponse.validate(data: data, response: response) + } + + public func send( + _ request: PlexRequest, + as type: T.Type + ) async throws(PlexError) -> T { + let data = try await send(request) + return try PlexResponse.decode(type, from: data) + } + + /// Baut die Adresse eines Bildes, ohne es zu laden. + /// + /// Plex kann Bilder serverseitig verkleinern — ein Poster in Kachelgröße + /// statt in Originalauflösung spart bei einer Reihe aus zwanzig Titeln ein + /// Vielfaches an Übertragung. + public nonisolated func artworkURL( + path: String, + width: Int, + height: Int, + token: String + ) -> URL? { + guard var components = URLComponents( + url: baseURL.appending(path: "/photo/:/transcode"), + resolvingAgainstBaseURL: false + ) else { return nil } + + components.queryItems = [ + URLQueryItem(name: "width", value: String(width)), + URLQueryItem(name: "height", value: String(height)), + URLQueryItem(name: "minSize", value: "1"), + URLQueryItem(name: "upscale", value: "1"), + URLQueryItem(name: "url", value: path), + // Hier muss der Token ausnahmsweise in die Adresse: das Bild lädt + // AsyncImage selbst, und dort lassen sich keine Kopfzeilen setzen. + URLQueryItem(name: "X-Plex-Token", value: token), + ] + return components.url + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift new file mode 100644 index 0000000..2a35721 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Sucht den besten erreichbaren Weg zu einem Server. +/// +/// Geht die Wege nach Rang durch — erst lokal, dann direkt von außen, zuletzt +/// über die Vermittlung von Plex. Innerhalb eines Rangs laufen die Versuche +/// gleichzeitig, weil sie sich gegenseitig nicht ausschließen und ein +/// unerreichbarer Weg sonst die volle Zeitüberschreitung kostet, bevor der +/// nächste überhaupt beginnt. +public struct PlexConnectionSelector: Sendable { + private let identity: PlexClientIdentity + private let transport: any PlexTransport + private let probeTimeout: Duration + + public init( + identity: PlexClientIdentity, + transport: any PlexTransport = URLSession.shared, + probeTimeout: Duration = .seconds(5) + ) { + self.identity = identity + self.transport = transport + self.probeTimeout = probeTimeout + } + + public func selectConnection(for resource: PlexResource) async -> PlexConnection? { + guard let token = resource.accessToken else { return nil } + + let tiers = Dictionary(grouping: resource.connections, by: \.tier) + for tier in tiers.keys.sorted() { + guard let candidates = tiers[tier] else { continue } + if let winner = await race(candidates, token: token, expecting: resource.clientIdentifier) { + return winner + } + } + return nil + } + + /// Lässt die Wege eines Rangs gegeneinander antreten; der erste, der sich + /// korrekt ausweist, gewinnt. + private func race( + _ connections: [PlexConnection], + token: String, + expecting clientIdentifier: String + ) async -> PlexConnection? { + await withTaskGroup(of: PlexConnection?.self) { group in + for connection in connections { + group.addTask { + await probe(connection, token: token, expecting: clientIdentifier) ? connection : nil + } + } + + for await result in group { + if let result { + // Die übrigen Versuche interessieren nicht mehr. Ohne das + // Abbrechen liefe eine unerreichbare Adresse noch bis zur + // Zeitüberschreitung weiter und hielte die Aufgabe offen. + group.cancelAll() + return result + } + } + return nil + } + } + + /// Fragt `/identity` ab — der einzige Endpunkt, der ohne Token antwortet + /// und die Kennung des Servers nennt. + private func probe( + _ connection: PlexConnection, + token: String, + expecting clientIdentifier: String + ) async -> Bool { + guard let baseURL = connection.url else { return false } + + let client = PlexHTTPClient( + baseURL: baseURL, + identity: identity, + token: token, + transport: transport + ) + + do { + let identityResponse = try await client.send( + PlexRequest(path: "/identity"), + as: PlexIdentity.self + ) + // Der Abgleich der Kennung ist keine Förmlichkeit: eine veraltete + // plex.direct-Adresse kann auf einen fremden Server zeigen, und + // ohne Prüfung würde die App fremde Mediatheken anzeigen. + return identityResponse.machineIdentifier == clientIdentifier + } catch { + return false + } + } +} + +/// Die Antwort von `/identity`. +public struct PlexIdentity: Sendable, Decodable { + public let machineIdentifier: String + public let version: String? + + private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" } + private enum InnerKeys: String, CodingKey { case machineIdentifier, version } + + public init(from decoder: any Decoder) throws { + let outer = try decoder.container(keyedBy: OuterKeys.self) + let inner = try outer.nestedContainer(keyedBy: InnerKeys.self, forKey: .mediaContainer) + self.machineIdentifier = try inner.decode(String.self, forKey: .machineIdentifier) + self.version = try inner.decodeIfPresent(String.self, forKey: .version) + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexResource.swift b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexResource.swift new file mode 100644 index 0000000..c00645d --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexResource.swift @@ -0,0 +1,131 @@ +import Foundation + +/// Ein Gerät, das am Plex-Konto hängt — für uns nur Server interessant. +public struct PlexResource: Sendable, Equatable, Decodable, Identifiable { + public let name: String + public let product: String? + public let productVersion: String? + public let platform: String? + public let clientIdentifier: String + public let provides: String + public let owned: Bool + /// Der Token für genau diesen Server. + /// + /// Nicht derselbe wie der Konto-Token: bei geteilten Servern bekommt man + /// einen eigenen, eingeschränkten. Wer stattdessen den Konto-Token + /// verwendet, bekommt bei fremden Servern wortlos 401. + public let accessToken: String? + public let connections: [PlexConnection] + + public var id: String { clientIdentifier } + + /// Ob es sich um einen Medienserver handelt — `provides` kann auch + /// „client", „player" oder eine Kombination sein. + public var isServer: Bool { + provides.split(separator: ",").contains("server") + } + + private enum CodingKeys: String, CodingKey { + case name, product, productVersion, platform, clientIdentifier + case provides, owned, accessToken, connections + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.name = try values.decode(String.self, forKey: .name) + self.product = try values.decodeIfPresent(String.self, forKey: .product) + self.productVersion = try values.decodeIfPresent(String.self, forKey: .productVersion) + self.platform = try values.decodeIfPresent(String.self, forKey: .platform) + self.clientIdentifier = try values.decode(String.self, forKey: .clientIdentifier) + self.provides = try values.decodeIfPresent(String.self, forKey: .provides) ?? "" + self.owned = try values.decodeIfPresent(Bool.self, forKey: .owned) ?? false + self.accessToken = try values.decodeIfPresent(String.self, forKey: .accessToken) + self.connections = try values.decodeIfPresent([PlexConnection].self, forKey: .connections) ?? [] + } + + public init( + name: String, + clientIdentifier: String, + provides: String = "server", + owned: Bool = true, + accessToken: String? = nil, + connections: [PlexConnection] = [], + product: String? = nil, + productVersion: String? = nil, + platform: String? = nil + ) { + self.name = name + self.product = product + self.productVersion = productVersion + self.platform = platform + self.clientIdentifier = clientIdentifier + self.provides = provides + self.owned = owned + self.accessToken = accessToken + self.connections = connections + } +} + +/// Ein Weg zu einem Server. +public struct PlexConnection: Sendable, Equatable, Decodable, Identifiable { + public let uri: String + public let address: String? + public let port: Int? + public let local: Bool + /// Ob der Weg über die Vermittlung von Plex läuft. Der ist immer + /// erreichbar, aber auf wenige Mbit gedrosselt — taugt für Metadaten, + /// nicht für 4K. + public let relay: Bool + public let `protocol`: String? + public let iPv6: Bool + + public var id: String { uri } + public var url: URL? { URL(string: uri) } + + private enum CodingKeys: String, CodingKey { + case uri, address, port, local, relay + case `protocol` + case iPv6 = "IPv6" + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.uri = try values.decode(String.self, forKey: .uri) + self.address = try values.decodeIfPresent(String.self, forKey: .address) + self.port = try values.decodeIfPresent(Int.self, forKey: .port) + self.local = try values.decodeIfPresent(Bool.self, forKey: .local) ?? false + self.relay = try values.decodeIfPresent(Bool.self, forKey: .relay) ?? false + self.protocol = try values.decodeIfPresent(String.self, forKey: .protocol) + self.iPv6 = try values.decodeIfPresent(Bool.self, forKey: .iPv6) ?? false + } + + public init( + uri: String, + local: Bool = false, + relay: Bool = false, + address: String? = nil, + port: Int? = nil, + protocol: String? = nil, + iPv6: Bool = false + ) { + self.uri = uri + self.local = local + self.relay = relay + self.address = address + self.port = port + self.protocol = `protocol` + self.iPv6 = iPv6 + } + + /// In welcher Reihenfolge die Wege probiert werden. + /// + /// Nicht „wer zuerst antwortet, gewinnt": die Vermittlung von Plex meldet + /// sich oft schneller als der Server im eigenen Netz, ist aber gedrosselt. + /// Ein Rennen ohne Rangordnung würde also ausgerechnet den langsamsten Weg + /// prämieren. + public var tier: Int { + if local { return 0 } + if relay { return 2 } + return 1 + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexServerDirectory.swift b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexServerDirectory.swift new file mode 100644 index 0000000..cd877bf --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexServerDirectory.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Holt die Serverliste vom Konto. +public actor PlexServerDirectory { + private let client: PlexHTTPClient + + public init( + identity: PlexClientIdentity, + token: String, + transport: any PlexTransport = URLSession.shared + ) { + self.client = PlexHTTPClient( + baseURL: URL(string: "https://plex.tv")!, + identity: identity, + token: token, + transport: transport + ) + } + + /// Alle Medienserver, auf die das Konto Zugriff hat — eigene und geteilte. + public func servers() async throws(PlexError) -> [PlexResource] { + let resources = try await client.send( + PlexRequest(path: "/api/v2/resources", query: [ + // Ohne diese beiden Schalter fehlen in der Antwort die + // plex.direct-Adressen und die Vermittlung — übrig bliebe nur + // die lokale IP, und außer Haus ginge gar nichts. + URLQueryItem(name: "includeHttps", value: "1"), + URLQueryItem(name: "includeRelay", value: "1"), + ]), + as: [PlexResource].self + ) + return resources.filter(\.isServer) + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/LibraryQuery.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/LibraryQuery.swift new file mode 100644 index 0000000..8a14817 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/LibraryQuery.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Filter und Sortierung einer Mediatheksansicht. +/// +/// Wird zu Abfrageparametern für Plex — die Filterung passiert also auf dem +/// Server. Bei zehntausend Titeln wäre alles andere aussichtslos: die Liste +/// erst vollständig zu laden, um sie dann lokal zu durchsuchen, dauert +/// Minuten und belegt entsprechend Speicher. +public struct LibraryQuery: Sendable, Equatable { + public enum Sort: String, Sendable, Equatable, CaseIterable { + case titleAscending = "titleSort:asc" + case titleDescending = "titleSort:desc" + case recentlyAdded = "addedAt:desc" + case releaseDate = "originallyAvailableAt:desc" + case rating = "rating:desc" + case lastViewed = "lastViewedAt:desc" + } + + public var sort: Sort + public var unwatchedOnly: Bool + public var genre: String? + public var year: Int? + public var contentRating: String? + public var resolution: String? + + public init( + sort: Sort = .titleAscending, + unwatchedOnly: Bool = false, + genre: String? = nil, + year: Int? = nil, + contentRating: String? = nil, + resolution: String? = nil + ) { + self.sort = sort + self.unwatchedOnly = unwatchedOnly + self.genre = genre + self.year = year + self.contentRating = contentRating + self.resolution = resolution + } + + /// Ob überhaupt gefiltert wird — steuert, ob die Oberfläche den + /// Filterknopf hervorhebt. + public var isFiltering: Bool { + unwatchedOnly || genre != nil || year != nil || contentRating != nil || resolution != nil + } + + func queryItems(libraryKind: PlexLibrary.Kind?) -> [URLQueryItem] { + var items: [URLQueryItem] = [URLQueryItem(name: "sort", value: sort.rawValue)] + + // Plex nummeriert die Arten: 1 Film, 2 Serie, 4 Folge. Ohne diese + // Angabe liefert eine Serienmediathek alle Folgen einzeln statt der + // Serien. + switch libraryKind { + case .movie: items.append(URLQueryItem(name: "type", value: "1")) + case .show: items.append(URLQueryItem(name: "type", value: "2")) + default: break + } + + if unwatchedOnly { items.append(URLQueryItem(name: "unwatched", value: "1")) } + if let genre { items.append(URLQueryItem(name: "genre", value: genre)) } + if let year { items.append(URLQueryItem(name: "year", value: String(year))) } + if let contentRating { items.append(URLQueryItem(name: "contentRating", value: contentRating)) } + if let resolution { items.append(URLQueryItem(name: "resolution", value: resolution)) } + + return items + } +} + +/// Ein Ausschnitt aus einer längeren Liste. +public struct PlexPage: Sendable { + public let items: [Element] + public let offset: Int + /// Wie viele Einträge es insgesamt gibt. + public let totalSize: Int + + public init(items: [Element], offset: Int, totalSize: Int) { + self.items = items + self.offset = offset + self.totalSize = totalSize + } + + public var hasMore: Bool { offset + items.count < totalSize } + public var nextOffset: Int { offset + items.count } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift new file mode 100644 index 0000000..644ddad --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift @@ -0,0 +1,211 @@ +import Foundation + +/// Ein verbundener Plex-Server. +/// +/// Die eine Stelle, an der die App mit ihrem Server spricht. Die Views kennen +/// weder Pfade noch Abfrageparameter — sie rufen Methoden auf. +public actor PlexServer { + public let resource: PlexResource + public let connection: PlexConnection + fileprivate let client: PlexHTTPClient + private let token: String + + public init?( + resource: PlexResource, + connection: PlexConnection, + identity: PlexClientIdentity, + transport: any PlexTransport = URLSession.shared + ) { + guard let baseURL = connection.url, let token = resource.accessToken else { return nil } + self.resource = resource + self.connection = connection + self.token = token + self.client = PlexHTTPClient( + baseURL: baseURL, + identity: identity, + token: token, + transport: transport + ) + } + + public nonisolated var name: String { resource.name } + + // MARK: - Mediatheken + + public func libraries() async throws(PlexError) -> [PlexLibrary] { + let container = try await client.send( + PlexRequest(path: "/library/sections"), + as: PlexMediaContainer.self + ) + return container.content + } + + // MARK: - Startseite + + /// Die fertig zusammengestellten Reihen für die Startseite. + public func homeHubs() async throws(PlexError) -> [PlexHub] { + let container = try await client.send( + PlexRequest(path: "/hubs", query: [ + // Leere Reihen wären auf der Startseite nur Überschriften ohne + // Inhalt. + URLQueryItem(name: "includeEmpty", value: "0"), + URLQueryItem(name: "count", value: "20"), + ]), + as: PlexMediaContainer.self + ) + return container.content.filter { !$0.items.isEmpty } + } + + /// Was als Nächstes dran ist: angefangene Filme und die nächste ungesehene + /// Folge je Serie. + public func continueWatching() async throws(PlexError) -> [PlexItem] { + let container = try await client.send( + PlexRequest(path: "/hubs/continueWatching/items"), + as: PlexMediaContainer.self + ) + return container.content + } + + // MARK: - Blättern + + public func items( + inLibrary library: PlexLibrary, + query: LibraryQuery = LibraryQuery(), + offset: Int = 0, + limit: Int = 60 + ) async throws(PlexError) -> PlexPage { + let container = try await client.send( + PlexRequest( + path: "/library/sections/\(library.key)/all", + query: query.queryItems(libraryKind: library.kind), + // Die Seitengröße steht in Kopfzeilen, nicht in der Adresse — + // eine Eigenheit von Plex. + additionalHeaders: [ + "X-Plex-Container-Start": String(offset), + "X-Plex-Container-Size": String(limit), + ] + ), + as: PlexMediaContainer.self + ) + + return PlexPage( + items: container.content, + offset: container.offset ?? offset, + // `totalSize` fehlt, wenn alles auf eine Seite passt. Dann ist die + // Gesamtzahl gleich der Anzahl der gelieferten Einträge. + totalSize: container.totalSize ?? (offset + container.content.count) + ) + } + + /// Ein einzelner Titel mit allem, was die Detailseite braucht. + public func metadata(ratingKey: String) async throws(PlexError) -> PlexItem { + let container = try await client.send( + PlexRequest(path: "/library/metadata/\(ratingKey)", query: [ + // Marker für Intro und Abspann. Sie werden schon jetzt + // mitgeholt, obwohl sie erst in Phase 2 ausgewertet werden — + // sie kosten nichts und ersparen später eine zweite Anfrage. + URLQueryItem(name: "includeMarkers", value: "1"), + URLQueryItem(name: "includeChapters", value: "1"), + URLQueryItem(name: "includeRelated", value: "1"), + ]), + as: PlexMediaContainer.self + ) + guard let item = container.content.first else { throw .notFound } + return item + } + + /// Die Staffeln einer Serie bzw. die Folgen einer Staffel. + public func children(of ratingKey: String) async throws(PlexError) -> [PlexItem] { + let container = try await client.send( + PlexRequest(path: "/library/metadata/\(ratingKey)/children"), + as: PlexMediaContainer.self + ) + return container.content + } + + // MARK: - Suche + + /// Sucht über alle Mediatheken. Plex gruppiert das Ergebnis selbst nach + /// Filmen, Serien, Folgen und Personen. + public func search(_ query: String, limit: Int = 30) async throws(PlexError) -> [PlexHub] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let container = try await client.send( + PlexRequest(path: "/hubs/search", query: [ + URLQueryItem(name: "query", value: trimmed), + URLQueryItem(name: "limit", value: String(limit)), + ]), + as: PlexMediaContainer.self + ) + return container.content.filter { !$0.items.isEmpty } + } + + // MARK: - Adressen + + /// Die Adresse eines Bildes in der gewünschten Größe. + public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? { + guard let path, !path.isEmpty else { return nil } + return client.artworkURL(path: path, width: width, height: height, token: token) + } + + /// Die Adresse für DirectPlay: die Datei, unverändert. + public nonisolated func directPlayURL(part: PlexPart) -> URL? { + streamURL(partKey: part.key, download: false) + } + + /// Dieselbe Datei, aber als Download angefordert. Der Unterschied liegt + /// nur im Schalter — Plex setzt daraufhin Kopfzeilen, die das Weiterladen + /// nach einem Abbruch erlauben. + public nonisolated func downloadURL(part: PlexPart) -> URL? { + streamURL(partKey: part.key, download: true) + } + + private nonisolated func streamURL(partKey: String, download: Bool) -> URL? { + guard let baseURL = connection.url, + var components = URLComponents( + url: baseURL.appending(path: partKey), + resolvingAgainstBaseURL: false + ) + else { return nil } + + var query = [URLQueryItem(name: "X-Plex-Token", value: token)] + if download { query.append(URLQueryItem(name: "download", value: "1")) } + components.queryItems = query + return components.url + } +} + +// MARK: - Filterwerte + +/// Ein Wert, nach dem gefiltert werden kann — ein Genre, ein Jahr, eine +/// Altersfreigabe. +public struct PlexFilterOption: Sendable, Equatable, Decodable, Identifiable { + public let key: String + public let title: String + + public var id: String { key } + + public init(key: String, title: String) { + self.key = key + self.title = title + } +} + +extension PlexServer { + /// Die Werte, die es in dieser Mediathek für ein Filterfeld gibt. + /// + /// Wird vom Server berechnet, nicht aus der Titelliste abgeleitet: die + /// Liste ist seitenweise geladen und kennt die Genres der übrigen + /// Tausenden Titel gar nicht. + public func filterOptions( + inLibrary library: PlexLibrary, + field: String + ) async throws(PlexError) -> [PlexFilterOption] { + let container = try await client.send( + PlexRequest(path: "/library/sections/\(library.key)/\(field)"), + as: PlexMediaContainer.self + ) + return container.content + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift index 872d41a..39d6403 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift @@ -6,7 +6,10 @@ import Foundation /// `type`. Das ist ungewohnt, aber praktisch: Suchergebnisse und Reihen auf /// der Startseite mischen die Arten, und ein gemeinsamer Typ erspart das /// Aufteilen bei jedem Abruf. -public struct PlexItem: Sendable, Equatable, Codable, Identifiable { +/// Nur `Decodable`: geschrieben wird dieser Typ nirgends. Der Offline-Bestand +/// in Phase 3 bekommt ein eigenes SwiftData-Modell, weil er andere Felder +/// braucht — lokaler Dateipfad, Ladezeitpunkt — und andere nicht. +public struct PlexItem: Sendable, Equatable, Decodable, Identifiable { public enum Kind: String, Sendable, Codable { case movie, show, season, episode, clip, collection } @@ -58,6 +61,15 @@ public struct PlexItem: Sendable, Equatable, Codable, Identifiable { public let media: [PlexMedia] + // Schlagwörter. Plex liefert sie nur bei der Einzelabfrage eines Titels + // mit, nicht in Listen — deshalb sind sie dort leer. + public let genres: [PlexTag] + public let directors: [PlexTag] + public let writers: [PlexTag] + /// Die Besetzung, mit Porträt und Rollennamen. + public let roles: [PlexTag] + public let markers: [PlexMarker] + private enum CodingKeys: String, CodingKey { case ratingKey, key, guid, type, title, titleSort, summary, year, duration case contentRating, rating, audienceRating @@ -67,6 +79,11 @@ public struct PlexItem: Sendable, Equatable, Codable, Identifiable { case viewCount, viewOffset, lastViewedAt, addedAt case leafCount, viewedLeafCount, childCount case media = "Media" + case genres = "Genre" + case directors = "Director" + case writers = "Writer" + case roles = "Role" + case markers = "Marker" } /// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen @@ -112,6 +129,11 @@ public struct PlexItem: Sendable, Equatable, Codable, Identifiable { self.childCount = try values.decodeIfPresent(Int.self, forKey: .childCount) self.media = try values.decodeIfPresent([PlexMedia].self, forKey: .media) ?? [] + self.genres = try values.decodeIfPresent([PlexTag].self, forKey: .genres) ?? [] + self.directors = try values.decodeIfPresent([PlexTag].self, forKey: .directors) ?? [] + self.writers = try values.decodeIfPresent([PlexTag].self, forKey: .writers) ?? [] + self.roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? [] + self.markers = try values.decodeIfPresent([PlexMarker].self, forKey: .markers) ?? [] } /// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt @@ -162,6 +184,7 @@ public struct PlexMediaContainer: Sendable, Decod case size, totalSize, offset, title1, title2 case metadata = "Metadata" case directory = "Directory" + case hub = "Hub" } public init(from decoder: any Decoder) throws { @@ -174,13 +197,15 @@ public struct PlexMediaContainer: Sendable, Decod self.title1 = try inner.decodeIfPresent(String.self, forKey: .title1) self.title2 = try inner.decodeIfPresent(String.self, forKey: .title2) - // `Metadata` bei Titeln, `Directory` bei Bibliotheken und Staffeln. - // Eine leere Mediathek liefert gar kein Feld — das ist kein Fehler, - // sondern bedeutet schlicht null Einträge. + // `Metadata` bei Titeln, `Directory` bei Mediatheken, `Hub` bei den + // Reihen der Startseite. Eine leere Mediathek liefert gar kein Feld — + // das ist kein Fehler, sondern bedeutet schlicht null Einträge. if let metadata = try inner.decodeIfPresent([Content].self, forKey: .metadata) { self.content = metadata } else if let directory = try inner.decodeIfPresent([Content].self, forKey: .directory) { self.content = directory + } else if let hub = try inner.decodeIfPresent([Content].self, forKey: .hub) { + self.content = hub } else { self.content = [] } diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift new file mode 100644 index 0000000..53f6d1a --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Eine Mediathek auf dem Server. +public struct PlexLibrary: Sendable, Equatable, Decodable, Identifiable { + public enum Kind: String, Sendable, Codable { + case movie, show, artist, photo + /// Live-TV und Aufnahmen. + case livetv + } + + /// Die Nummer des Abschnitts, als Zeichenkette. Steckt in fast jedem + /// weiteren Pfad. + public let key: String + public let title: String + public let type: String + public let uuid: String? + public let art: String? + public let thumb: String? + public let updatedAt: Int? + + public var id: String { key } + public var kind: Kind? { Kind(rawValue: type) } + + /// Ob Flixy diese Mediathek darstellen kann. Musik und Fotos sind laut + /// Plan nicht vorgesehen — sie erscheinen deshalb gar nicht erst in der + /// Seitenleiste, statt dort auf einen leeren Bildschirm zu führen. + public var isSupported: Bool { + kind == .movie || kind == .show + } + + public init( + key: String, + title: String, + type: String, + uuid: String? = nil, + art: String? = nil, + thumb: String? = nil, + updatedAt: Int? = nil + ) { + self.key = key + self.title = title + self.type = type + self.uuid = uuid + self.art = art + self.thumb = thumb + self.updatedAt = updatedAt + } +} + +/// Eine Reihe auf der Startseite. +/// +/// Plex nennt sie „Hub" und liefert sie fertig zusammengestellt — Weiterschauen, +/// Zuletzt hinzugefügt, Sammlungen. Die App muss diese Reihen also nicht selbst +/// bauen, sondern nur darstellen. +public struct PlexHub: Sendable, Equatable, Decodable, Identifiable { + /// Stabiler Bezeichner wie `home.continue` oder `movie.recentlyadded`. + /// Danach richtet sich die Sortierung in den Einstellungen. + public let hubIdentifier: String + public let title: String + public let type: String? + public let key: String? + public let size: Int? + /// Ob es mehr Einträge gibt, als die Reihe zeigt — Grundlage für ein + /// „Alle anzeigen" am Ende. + public let more: Bool + public let items: [PlexItem] + + public var id: String { hubIdentifier } + + private enum CodingKeys: String, CodingKey { + case hubIdentifier, title, type, key, size, more + case items = "Metadata" + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.title = try values.decode(String.self, forKey: .title) + // Ältere Serverfassungen liefern keinen `hubIdentifier`. Der Titel ist + // dann der einzige verfügbare Schlüssel — schlechter, aber besser als + // eine Antwort, die gar nicht lesbar ist. + self.hubIdentifier = try values.decodeIfPresent(String.self, forKey: .hubIdentifier) ?? title + self.type = try values.decodeIfPresent(String.self, forKey: .type) + self.key = try values.decodeIfPresent(String.self, forKey: .key) + self.size = try values.decodeIfPresent(Int.self, forKey: .size) + self.more = try values.decodeIfPresent(Bool.self, forKey: .more) ?? false + self.items = try values.decodeIfPresent([PlexItem].self, forKey: .items) ?? [] + } + + public init( + hubIdentifier: String, + title: String, + type: String? = nil, + key: String? = nil, + size: Int? = nil, + more: Bool = false, + items: [PlexItem] = [] + ) { + self.hubIdentifier = hubIdentifier + self.title = title + self.type = type + self.key = key + self.size = size + self.more = more + self.items = items + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift new file mode 100644 index 0000000..8ce9cb2 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Ein Schlagwort am Titel: Genre, Regie, Rolle, Land. +/// +/// Plex gibt sie alle in derselben Form aus und unterscheidet nur über den +/// Feldnamen. `role` ist dabei nur bei Besetzungseinträgen gefüllt und trägt +/// den Namen der gespielten Figur. +public struct PlexTag: Sendable, Equatable, Hashable, Decodable, Identifiable { + public let tagKey: String? + public let tag: String + public let role: String? + public let thumb: String? + + private let rawID: Int? + + /// Eigene Kennung statt `id` vom Server: Genres kommen ohne, und zwei + /// Einträge ohne Kennung würden in `ForEach` zusammenfallen. + public var id: String { tagKey ?? rawID.map(String.init) ?? tag } + + private enum CodingKeys: String, CodingKey { + case rawID = "id" + case tagKey, tag, role, thumb + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.rawID = try values.decodeIfPresent(Int.self, forKey: .rawID) + self.tagKey = try values.decodeIfPresent(String.self, forKey: .tagKey) + self.tag = try values.decode(String.self, forKey: .tag) + self.role = try values.decodeIfPresent(String.self, forKey: .role) + self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb) + } + + public init(tag: String, role: String? = nil, thumb: String? = nil, tagKey: String? = nil) { + self.rawID = nil + self.tagKey = tagKey + self.tag = tag + self.role = role + self.thumb = thumb + } +} + +/// Eine Sprungmarke in einer Datei: Intro oder Abspann. +/// +/// Wird schon in Phase 1 mitgelesen, obwohl sie erst in Phase 2 ausgewertet +/// wird — sie kostet nichts und erspart später eine zweite Anfrage. +public struct PlexMarker: Sendable, Equatable, Decodable, Identifiable { + public let id: Int + public let type: String + /// In Millisekunden ab Beginn. + public let startTimeOffset: Int + public let endTimeOffset: Int + + public var isIntro: Bool { type == "intro" } + public var isCredits: Bool { type == "credits" } + + public init(id: Int, type: String, startTimeOffset: Int, endTimeOffset: Int) { + self.id = id + self.type = type + self.startTimeOffset = startTimeOffset + self.endTimeOffset = endTimeOffset + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/ConnectionSelectorTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/ConnectionSelectorTests.swift new file mode 100644 index 0000000..615f04a --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/ConnectionSelectorTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing +@testable import PlexKit + +@Suite("Verbindungsauswahl") +struct ConnectionSelectorTests { + private let serverID = "server-abc-123" + + private func identityResponse(_ machineIdentifier: String) -> String { + #"{"MediaContainer":{"machineIdentifier":"\#(machineIdentifier)","version":"1.42.0"}}"# + } + + private func resource(connections: [PlexConnection]) -> PlexResource { + PlexResource( + name: "Wohnzimmer", + clientIdentifier: serverID, + accessToken: "server-token", + connections: connections + ) + } + + @Test("Der lokale Weg gewinnt, auch wenn die Vermittlung erreichbar ist") + func localWinsOverRelay() async { + // Der eigentliche Zweck der Rangordnung: die Vermittlung von Plex + // antwortet im Zweifel schneller, ist aber gedrosselt. Ein Rennen ohne + // Rang würde ausgerechnet den langsamsten Weg krönen. + let transport = FakeTransport.routes(["/identity": identityResponse(serverID)]) + let selector = PlexConnectionSelector(identity: .test, transport: transport) + + let chosen = await selector.selectConnection(for: resource(connections: [ + PlexConnection(uri: "https://relay.plex.direct:443", relay: true), + PlexConnection(uri: "http://192.168.1.10:32400", local: true), + PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"), + ])) + + #expect(chosen?.uri == "http://192.168.1.10:32400") + } + + @Test("Der direkte Weg von außen kommt vor der Vermittlung") + func directRemoteBeatsRelay() async { + let transport = FakeTransport.routes(["/identity": identityResponse(serverID)]) + let selector = PlexConnectionSelector(identity: .test, transport: transport) + + let chosen = await selector.selectConnection(for: resource(connections: [ + PlexConnection(uri: "https://relay.plex.direct:443", relay: true), + PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"), + ])) + + #expect(chosen?.uri == "https://1-2-3-4.abc.plex.direct:32400") + } + + @Test("Ein Server mit fremder Kennung wird abgelehnt") + func rejectsWrongServer() async { + // Eine veraltete plex.direct-Adresse kann auf einen fremden Server + // zeigen. Ohne Abgleich der Kennung zeigte die App fremde Mediatheken. + let transport = FakeTransport.routes(["/identity": identityResponse("ein-anderer-server")]) + let selector = PlexConnectionSelector(identity: .test, transport: transport) + + let chosen = await selector.selectConnection(for: resource(connections: [ + PlexConnection(uri: "http://192.168.1.10:32400", local: true), + ])) + + #expect(chosen == nil) + } + + @Test("Ohne erreichbaren Weg gibt es kein Ergebnis") + func noReachableConnection() async { + let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable()) + + let chosen = await selector.selectConnection(for: resource(connections: [ + PlexConnection(uri: "http://192.168.1.10:32400", local: true), + PlexConnection(uri: "https://relay.plex.direct:443", relay: true), + ])) + + #expect(chosen == nil) + } + + @Test("Ohne Server-Token wird gar nicht erst probiert") + func missingAccessTokenFailsFast() async { + // Bei geteilten Servern liefert plex.tv gelegentlich keinen Token — + // etwa wenn die Freigabe zurückgezogen wurde. Anfragen ohne ihn + // beantwortet der Server wortlos mit 401. + let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable()) + let resource = PlexResource( + name: "Geteilt", + clientIdentifier: serverID, + accessToken: nil, + connections: [PlexConnection(uri: "http://192.168.1.10:32400", local: true)] + ) + + #expect(await selector.selectConnection(for: resource) == nil) + } + + @Test("Die Rangordnung der Wege stimmt") + func tierOrdering() { + #expect(PlexConnection(uri: "x", local: true).tier == 0) + #expect(PlexConnection(uri: "x").tier == 1) + #expect(PlexConnection(uri: "x", relay: true).tier == 2) + } +} + +@Suite("Serverliste") +struct ServerDirectoryTests { + private let resourcesJSON = """ + [ + { + "name": "Wohnzimmer", + "product": "Plex Media Server", + "clientIdentifier": "server-abc", + "provides": "server", + "owned": true, + "accessToken": "token-eigen", + "connections": [ + { "uri": "http://192.168.1.10:32400", "address": "192.168.1.10", "port": 32400, "local": true, "relay": false, "protocol": "http", "IPv6": false }, + { "uri": "https://1-2-3-4.abc.plex.direct:32400", "local": false, "relay": false, "protocol": "https" } + ] + }, + { + "name": "iPhone von Guido", + "clientIdentifier": "client-xyz", + "provides": "client,player", + "owned": true, + "connections": [] + }, + { + "name": "Server eines Freundes", + "clientIdentifier": "server-def", + "provides": "server", + "owned": false, + "accessToken": "token-geteilt", + "connections": [ + { "uri": "https://5-6-7-8.def.plex.direct:32400", "local": false, "relay": false } + ] + } + ] + """ + + @Test("Nur Server erscheinen in der Liste, keine Abspielgeräte") + func filtersOutNonServers() async throws { + // Das Konto listet auch jedes iPhone und jeden Fernseher, der je + // angemeldet war. Ungefiltert stünden sie alle in der Serverauswahl. + let directory = PlexServerDirectory( + identity: .test, + token: "konto-token", + transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON]) + ) + + let servers = try await directory.servers() + #expect(servers.count == 2) + #expect(!servers.contains { $0.clientIdentifier == "client-xyz" }) + } + + @Test("Geteilte Server behalten ihren eigenen Token") + func sharedServersKeepTheirOwnToken() async throws { + let directory = PlexServerDirectory( + identity: .test, + token: "konto-token", + transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON]) + ) + + let servers = try await directory.servers() + let shared = try #require(servers.first { !$0.owned }) + + #expect(shared.accessToken == "token-geteilt") + #expect(shared.accessToken != "konto-token") + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LibraryQueryTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LibraryQueryTests.swift new file mode 100644 index 0000000..5e54068 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/LibraryQueryTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing +@testable import PlexKit + +@Suite("Mediatheks-Abfragen") +struct LibraryQueryTests { + private func value(_ items: [URLQueryItem], _ name: String) -> String? { + items.first { $0.name == name }?.value + } + + @Test("Eine Serienmediathek liefert Serien, nicht einzelne Folgen") + func showLibraryRequestsShows() { + // Ohne `type=2` schüttet Plex alle Folgen einzeln aus — bei einer + // Serienmediathek sind das schnell zehntausend Einträge statt dreißig + // Serien. + let items = LibraryQuery().queryItems(libraryKind: .show) + #expect(value(items, "type") == "2") + } + + @Test("Eine Filmmediathek fragt nach Filmen") + func movieLibraryRequestsMovies() { + #expect(value(LibraryQuery().queryItems(libraryKind: .movie), "type") == "1") + } + + @Test("Unbekannte Mediathekenarten bekommen keine Artangabe") + func unknownKindOmitsType() { + #expect(value(LibraryQuery().queryItems(libraryKind: nil), "type") == nil) + } + + @Test("Sortierung landet immer in der Abfrage") + func sortIsAlwaysPresent() { + for sort in LibraryQuery.Sort.allCases { + let items = LibraryQuery(sort: sort).queryItems(libraryKind: .movie) + #expect(value(items, "sort") == sort.rawValue) + } + } + + @Test("Ohne Filter stehen keine Filterparameter in der Abfrage") + func unsetFiltersAreOmitted() { + // Ein leerer Parameter ist für Plex nicht dasselbe wie ein fehlender: + // `genre=` filtert auf ein Genre ohne Namen und liefert nichts. + let items = LibraryQuery().queryItems(libraryKind: .movie) + #expect(value(items, "genre") == nil) + #expect(value(items, "year") == nil) + #expect(value(items, "unwatched") == nil) + #expect(value(items, "resolution") == nil) + } + + @Test("Gesetzte Filter landen vollständig in der Abfrage") + func setFiltersAppear() { + let query = LibraryQuery( + sort: .recentlyAdded, + unwatchedOnly: true, + genre: "18", + year: 2017, + resolution: "4k" + ) + let items = query.queryItems(libraryKind: .movie) + + #expect(value(items, "sort") == "addedAt:desc") + #expect(value(items, "unwatched") == "1") + #expect(value(items, "genre") == "18") + #expect(value(items, "year") == "2017") + #expect(value(items, "resolution") == "4k") + } + + @Test("Der Filterknopf weiß, ob gefiltert wird") + func filteringFlagReflectsState() { + #expect(!LibraryQuery().isFiltering) + // Die Sortierung allein zählt nicht als Filter — sonst wäre der Knopf + // dauerhaft hervorgehoben. + #expect(!LibraryQuery(sort: .rating).isFiltering) + #expect(LibraryQuery(unwatchedOnly: true).isFiltering) + #expect(LibraryQuery(genre: "18").isFiltering) + } +} + +@Suite("Seitenweises Nachladen") +struct PlexPageTests { + @Test("Eine volle Seite kündigt Nachschub an") + func fullPageHasMore() { + let page = PlexPage(items: Array(0..<60), offset: 0, totalSize: 250) + #expect(page.hasMore) + #expect(page.nextOffset == 60) + } + + @Test("Die letzte Seite kündigt keinen Nachschub an") + func lastPageHasNoMore() { + // Genau die Bedingung, die sonst zu einer Endlosschleife führt: die + // Ansicht lädt nach, bekommt nichts, lädt wieder nach. + let page = PlexPage(items: Array(0..<10), offset: 240, totalSize: 250) + #expect(!page.hasMore) + } + + @Test("Eine leere Mediathek verlangt kein Nachladen") + func emptyLibraryHasNoMore() { + let page = PlexPage(items: [Int](), offset: 0, totalSize: 0) + #expect(!page.hasMore) + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Support/FakeTransport.swift b/Packages/FlixyKit/Tests/PlexKitTests/Support/FakeTransport.swift new file mode 100644 index 0000000..1156631 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/Support/FakeTransport.swift @@ -0,0 +1,57 @@ +import Foundation +@testable import PlexKit + +/// Ein Transport, der vorbereitete Antworten liefert. +/// +/// Statt `URLProtocol` zu unterwandern: das ist global und bringt bei parallel +/// laufenden Tests die Antworten durcheinander. Hier hat jeder Test seinen +/// eigenen Transport. +struct FakeTransport: PlexTransport { + let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await handler(request) + } + + /// Antwortet auf jeden Pfad, der als Schlüssel hinterlegt ist, und mit 404 + /// auf alle übrigen. + static func routes( + _ routes: [String: String], + status: Int = 200 + ) -> FakeTransport { + FakeTransport { request in + let path = request.url?.path() ?? "" + guard let body = routes.first(where: { path.hasSuffix($0.key) })?.value else { + return (Data(), response(for: request, status: 404)) + } + return (Data(body.utf8), response(for: request, status: status)) + } + } + + /// Verweigert jede Verbindung — für die Wege, die im Test nicht gewinnen + /// sollen. + static func unreachable() -> FakeTransport { + FakeTransport { _ in throw URLError(.cannotConnectToHost) } + } + + static func response(for request: URLRequest, status: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url ?? URL(string: "http://example.invalid")!, + statusCode: status, + httpVersion: nil, + headerFields: nil + )! + } +} + +extension PlexClientIdentity { + static let test = PlexClientIdentity( + product: "FlixyTests", + version: "0", + clientIdentifier: "test-client", + platform: "iOS", + platformVersion: "26.0", + device: "iPhone", + deviceName: "Testgerät" + ) +} diff --git a/Packages/FlixyVLC/Package.resolved b/Packages/FlixyVLC/Package.resolved new file mode 100644 index 0000000..c362a13 --- /dev/null +++ b/Packages/FlixyVLC/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "b573b58f837dec84b5981e792f7548e0bb126412c181987af9b265133a652e02", + "pins" : [ + { + "identity" : "vlckit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/videolan/vlckit.git", + "state" : { + "revision" : "6cbc4e7b248aa51ec0906697abb30ecae47194b7", + "version" : "4.0.0-a24" + } + } + ], + "version" : 3 +} diff --git a/Packages/FlixyVLC/Package.swift b/Packages/FlixyVLC/Package.swift new file mode 100644 index 0000000..ee0cda8 --- /dev/null +++ b/Packages/FlixyVLC/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.2 +import PackageDescription + +/// VLCKit steckt bewusst in einem eigenen Package, nicht in FlixyKit. +/// +/// Das Binärpaket ist 920 MB groß und entpackt sich zu rund 2,7 GB. Läge es im +/// Abhängigkeitsbaum von FlixyKit, zöge es jeder `swift test`-Lauf hinter sich +/// her — und aus drei Sekunden Testlaufzeit würden Minuten. +let package = Package( + name: "FlixyVLC", + defaultLocalization: "en", + platforms: [.iOS(.v26)], + products: [ + .library(name: "FlixyVLC", targets: ["FlixyVLC"]), + ], + dependencies: [ + .package(name: "FlixyKit", path: "../FlixyKit"), + // Genau festgenagelt: die 4.0-Reihe ist noch im Alpha-Stadium, und die + // API hat sich zwischen den Vorabversionen mehrfach geändert. + .package(url: "https://github.com/videolan/vlckit.git", exact: "4.0.0-a24"), + ], + targets: [ + .target( + name: "FlixyVLC", + dependencies: [ + .product(name: "FlixyPlayback", package: "FlixyKit"), + .product(name: "VLCKit", package: "vlckit"), + ] + ), + ] +) diff --git a/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift b/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift new file mode 100644 index 0000000..1895909 --- /dev/null +++ b/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift @@ -0,0 +1,241 @@ +import AVFoundation +import Foundation +import FlixyPlayback +import UIKit +@preconcurrency import VLCKit + +/// Wiedergabe über VLCKit. +/// +/// Der Weg für alles, was der Systemplayer nicht öffnet: Matroska-Container, +/// DTS- und TrueHD-Ton, ältere Codecs. Langsamer beim Starten und stromhungriger, +/// dafür spielt er praktisch jede Datei ohne Zutun des Servers. +/// +/// `@preconcurrency import`, weil VLCKit als Objective-C-Bibliothek nichts über +/// Nebenläufigkeit aussagt. Die ganze Klasse liegt deshalb auf dem Hauptakteur — +/// VLCKit erwartet seine Rückrufe ohnehin dort. +@MainActor +public final class VLCPlayerEngine: NSObject, VideoPlayerEngine { + /// Die Fläche, in die VLC zeichnet. Die Ansicht hängt sie in ihren + /// Hierarchiebaum. + public let videoView = UIView() + + private let player = VLCMediaPlayer() + + public private(set) var state: PlaybackState = .idle { + didSet { + guard state != oldValue else { return } + continuation?.yield(.stateChanged(state)) + } + } + + public private(set) var duration: Duration? + public private(set) var audioTracks: [MediaTrack] = [] + public private(set) var subtitleTracks: [MediaTrack] = [] + + public var currentTime: Duration { + .milliseconds(Int(player.time.intValue)) + } + + /// VLCKit 4 bringt ein eigenes Bild-in-Bild-Protokoll mit + /// (`VLCPictureInPictureMediaControlling`). Angebunden ist es noch nicht — + /// solange steht hier `false`, damit kein Knopf erscheint, der nichts tut. + /// Nachzuholen in Phase 2, zusammen mit der Steuerung im Sperrbildschirm. + public let supportsPictureInPicture = false + + public private(set) lazy var events: AsyncStream = { + AsyncStream { continuation in + self.continuation = continuation + } + }() + + private var continuation: AsyncStream.Continuation? + private var pendingStartTime: Duration = .zero + + public override init() { + super.init() + videoView.backgroundColor = .black + player.delegate = self + player.drawable = videoView + AudioSessionBridge.activate() + } + + public func load(_ source: PlaybackSource) async throws(PlaybackFailure) { + state = .loading + pendingStartTime = source.startTime + + let media = VLCMedia(url: source.url) + player.media = media + + // VLC kennt Länge und Spuren erst, wenn es die Datei geöffnet hat. + // Beides kommt später über den Delegaten — hier gibt es nichts zu + // warten, und ein `await` würde nur den Bildaufbau verzögern. + player.play() + } + + public func play() { + player.play() + } + + public func pause() { + // `pause()` ist bei VLC ein Umschalter. Ohne diese Prüfung würde ein + // Aufruf im pausierten Zustand die Wiedergabe wieder starten. + if player.isPlaying { player.pause() } + } + + public func seek(to time: Duration) async { + player.time = VLCTime(int: time.vlcMilliseconds) + continuation?.yield(.timeChanged(time)) + } + + public func setRate(_ rate: Double) { + player.rate = Float(rate) + } + + public func selectAudioTrack(_ track: MediaTrack?) { + select(track, from: player.audioTracks) + } + + public func selectSubtitleTrack(_ track: MediaTrack?) { + select(track, from: player.textTracks) + } + + public func teardown() { + player.stop() + player.delegate = nil + player.drawable = nil + continuation?.finish() + continuation = nil + state = .idle + AudioSessionBridge.deactivate() + } + + // MARK: - Spuren + + private func select(_ track: MediaTrack?, from tracks: [VLCMediaPlayer.Track]) { + guard let track, let identifier = track.engineTrackID else { + // Abschalten: alle Spuren dieser Art abwählen. Bei Untertiteln der + // Normalfall. + tracks.forEach { $0.isSelected = false } + return + } + // `isSelectedExclusively` wählt diese Spur und wählt die übrigen + // desselben Typs ab. Einzeln zu setzen ließe zwei Tonspuren gleichzeitig + // laufen. + tracks.first { $0.trackId == identifier }?.isSelectedExclusively = true + } + + private func refreshTracks() { + audioTracks = player.audioTracks.map { $0.mediaTrack(kind: .audio) } + subtitleTracks = player.textTracks.map { $0.mediaTrack(kind: .subtitle) } + continuation?.yield(.tracksResolved(audio: audioTracks, subtitle: subtitleTracks)) + } +} + +// MARK: - VLCMediaPlayerDelegate + +/// VLCKit stellt seine Rückrufe standardmäßig über die Hauptwarteschlange zu +/// (siehe `VLCEventsConfiguration`). Deshalb `assumeIsolated` statt eines +/// `Task`: ein nachgelagerter Task würde die Reihenfolge der Zustandswechsel +/// durcheinanderbringen — „spielt" könnte nach „gestoppt" ankommen. +extension VLCPlayerEngine: VLCMediaPlayerDelegate { + public nonisolated func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { + MainActor.assumeIsolated { + switch newState { + case .opening: + state = .loading + case .playing: + state = .playing + applyPendingStartTime() + refreshTracks() + case .paused: + state = .paused + case .stopped, .stopping: + // VLC meldet das Ende einer Datei als „gestoppt". Ob das + // Ansehen abgeschlossen ist oder jemand abgebrochen hat, + // unterscheidet nur die Position. + state = isNearEnd ? .ended : .idle + if isNearEnd { continuation?.yield(.reachedEnd) } + case .error: + state = .failed(PlaybackFailure(kind: .formatUnsupported)) + case .nothingSpecial: + break + @unknown default: + break + } + } + } + + public nonisolated func mediaPlayerTimeChanged(_ aNotification: Notification) { + MainActor.assumeIsolated { + continuation?.yield(.timeChanged(currentTime)) + } + } + + public nonisolated func mediaPlayerLengthChanged(_ length: Int64) { + MainActor.assumeIsolated { + guard length > 0 else { return } + let value = Duration.milliseconds(length) + duration = value + continuation?.yield(.durationResolved(value)) + } + } + + /// Beim Fortsetzen: VLC nimmt eine Position erst an, wenn die Wiedergabe + /// tatsächlich läuft. Vorher gesetzt, wird sie kommentarlos verworfen. + private func applyPendingStartTime() { + guard pendingStartTime > .zero else { return } + player.time = VLCTime(int: pendingStartTime.vlcMilliseconds) + pendingStartTime = .zero + } + + private var isNearEnd: Bool { + // Ein Rest von unter zwei Sekunden zählt als durchgesehen — VLC + // stoppt selten exakt am Dateiende. + guard let duration else { return false } + return currentTime + .seconds(2) >= duration + } +} + +extension Duration { + /// Millisekunden als `Int32`, wie VLCTime sie erwartet. + /// + /// Abgeschnitten statt umgewandelt: `Int32(...)` stürzt bei Überlauf ab, + /// und die Grenze liegt bei rund 24 Tagen Laufzeit. Das wird keine Datei + /// erreichen — aber ein Absturz an dieser Stelle wäre besonders schwer zu + /// erklären. + var vlcMilliseconds: Int32 { + let milliseconds = components.seconds * 1000 + return Int32(clamping: milliseconds) + } +} + +extension VLCMediaPlayer.Track { + fileprivate func mediaTrack(kind: MediaTrack.Kind) -> MediaTrack { + MediaTrack( + id: "vlc-\(trackId)", + kind: kind, + displayName: trackName.isEmpty ? trackId : trackName, + engineIdentifier: nil + ) + } +} + +extension MediaTrack { + /// VLC führt seine Spuren unter Zeichenketten, nicht unter Zahlen — + /// deshalb steckt die Kennung im `id`-Feld statt in `engineIdentifier`. + fileprivate var engineTrackID: String? { + id.hasPrefix("vlc-") ? String(id.dropFirst(4)) : nil + } +} + +/// Die Tonsitzung, damit der Ton auch bei umgelegtem Stummschalter läuft. +enum AudioSessionBridge { + static func activate() { + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try? AVAudioSession.sharedInstance().setActive(true) + } + + static func deactivate() { + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } +} diff --git a/project.yml b/project.yml index 7645300..f770f60 100644 --- a/project.yml +++ b/project.yml @@ -19,6 +19,10 @@ settings: packages: FlixyKit: path: Packages/FlixyKit + # Eigenes Package, weil das VLCKit-Binärpaket 920 MB wiegt und sonst bei + # jedem `swift test` in FlixyKit mitgezogen würde. + FlixyVLC: + path: Packages/FlixyVLC targets: Flixy: @@ -35,6 +39,8 @@ targets: product: FlixyPlayback - package: FlixyKit product: FlixyDownloads + - package: FlixyVLC + product: FlixyVLC settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy