From 3f6c6d8816c4cc93b18ba5b4d53c8d97079df11f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 13 Sep 2026 14:45:16 +0200 Subject: [PATCH] =?UTF-8?q?Konto=20unten=20in=20der=20Seitenleiste=20?= =?UTF-8?q?=E2=80=94=20und=20eine=20Einstellung,=20die=20es=20nicht=20gab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Dinge aus deinen Einstellungs-Bildern. **Das Konto sitzt jetzt unten in der Seitenleiste**, nicht als gleichrangiger Eintrag zwischen den Mediatheken. Dort sucht man es, und von dort führt der Weg in die Einstellungen und zum Abmelden. Auch das ist eine Systemstelle — `tabViewSidebarBottomBar` —, also dieselbe Zeile, an der sie bei JellyTV steht. Auf dem iPhone gibt es sie nicht; dort bleibt es beim eigenen Eintrag. Der Buchstabe auf der Scheibe ist kein Platzhalter für ein Bild, das noch kommt: Jellyfin gibt kein Profilbild heraus, solange keines hinterlegt ist, und Plex kennt den Kontonamen am Server gar nicht. Bei Plex steht deshalb der Servername oben und darunter nichts — statt zweimal dasselbe. **Ton- und Untertitelsprache lassen sich jetzt einstellen.** Beim Durchsehen deiner Bilder ist mir aufgefallen, dass es diese Vorliebe längst gibt: der Player liest sie bei jeder Wiedergabe aus und wählt die passende Spur. Nur äußern konnte man sie nirgends — sie stand in den Voreinstellungen und blieb dort für immer leer. Eine Vorliebe, die niemand äußern kann, ist keine. Fünfzehn Sprachen, keine vollständige Liste: ein Rad mit zweihundert Einträgen, um „Deutsch" zu finden, wäre keine Hilfe. Was fehlt, kommt später mit einem Suchfeld dazu. Gebaut und übersetzt, aber **nicht angesehen**: der Simulator-Tunnel ist mitten im Prüfen abgestürzt und ließ sich nicht wieder aufbauen. Dass es die Systemstelle gibt, beweist der Compiler; wie sie aussieht, habe ich diesmal nicht gesehen. 258 Tests grün. Co-Authored-By: Claude Opus 5 --- Flixy/Resources/Localizable.xcstrings | 167 ++++++++++++++- Flixy/Views/Navigation/AccountBar.swift | 94 +++++++++ Flixy/Views/Navigation/RootView.swift | 6 + Flixy/Views/Settings/LanguagesView.swift | 65 ++++++ Flixy/Views/Settings/SettingsView.swift | 6 + .../Sources/JellyfinKit/Localizable.xcstrings | 191 +++++++++--------- 6 files changed, 433 insertions(+), 96 deletions(-) create mode 100644 Flixy/Views/Navigation/AccountBar.swift create mode 100644 Flixy/Views/Settings/LanguagesView.swift diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 05d35ec..71d05db 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -33,6 +33,22 @@ "comment": "A label for a 720p movie.", "isCommentAutoGenerated": true }, + "account.settings": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + } + } + }, "action.addFavourite": { "extractionState": "stale", "localizations": { @@ -354,6 +370,7 @@ } }, "appearance.navigation": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -370,6 +387,7 @@ } }, "appearance.navigationExplanation": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -386,6 +404,7 @@ } }, "appearance.navigationSide": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -402,6 +421,7 @@ } }, "appearance.navigationTop": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1890,6 +1910,150 @@ } } }, + "languages.audio": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tonspur" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio track" + } + } + } + }, + "languages.audioExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gibt es beim Abspielen eine Tonspur in dieser Sprache, wird sie von selbst gewählt. Sonst bleibt es bei der Spur, die der Server voranstellt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "If a track in this language exists, it is selected automatically. Otherwise the server's own first track is used." + } + } + } + }, + "languages.audioHeader": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ton" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio" + } + } + } + }, + "languages.serverDefault": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie der Server vorgibt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "As the server prefers" + } + } + } + }, + "languages.subtitles": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Untertitel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Subtitles" + } + } + } + }, + "languages.subtitlesExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Untertitel werden nur eingeschaltet, wenn es sie in dieser Sprache gibt. „Aus“ heißt: gar keine, bis du sie im Player selbst einschaltest." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Subtitles are only switched on when they exist in this language. “Off” means none until you turn them on in the player." + } + } + } + }, + "languages.subtitlesHeader": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Untertitel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Subtitles" + } + } + } + }, + "languages.subtitlesOff": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + } + } + }, + "languages.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ton & Untertitel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio & subtitles" + } + } + } + }, "library.clearFilters": { "extractionState": "stale", "localizations": { @@ -3911,6 +4075,7 @@ }, "sidebar.close": { "comment": "Barrierefreiheit: abgedunkelte Fläche", + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -3963,7 +4128,6 @@ }, "sidebar.libraries": { "comment": "Überschrift über der Liste der Mediatheken", - "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4016,6 +4180,7 @@ }, "sidebar.open": { "comment": "Barrierefreiheit: Hamburger-Knopf", + "extractionState": "stale", "localizations": { "de": { "stringUnit": { diff --git a/Flixy/Views/Navigation/AccountBar.swift b/Flixy/Views/Navigation/AccountBar.swift new file mode 100644 index 0000000..9c8cbba --- /dev/null +++ b/Flixy/Views/Navigation/AccountBar.swift @@ -0,0 +1,94 @@ +import SwiftUI +import FlixyDesign + +/// Wer angemeldet ist — unten in der Seitenleiste. +/// +/// Der Platz ist nicht zufällig: dort sucht man sein Konto, weil es auf jedem +/// Schreibtisch und in jeder App der letzte Eintrag ist. Und von dort führt +/// der Weg in die Einstellungen, statt dass sie als gleichrangiger Eintrag +/// zwischen den Mediatheken stehen. +/// +/// Auf dem iPhone gibt es diese Leiste nicht — dort gehört das Konto in die +/// Einstellungen selbst, und die haben ihren eigenen Eintrag. +struct AccountBar: View { + @Binding var selection: SidebarDestination + + @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent + @State private var showsSignOutConfirmation = false + + private var title: String { + session.accountName ?? session.serverName + } + + /// Der Server steht nur dann darunter, wenn oben schon der Kontoname + /// steht — sonst stünde zweimal dasselbe. + private var subtitle: String? { + session.accountName == nil ? nil : session.serverName + } + + var body: some View { + Menu { + Button("account.settings", systemImage: "gearshape") { + selection = .settings + } + + Divider() + + Button("action.signOut", systemImage: "rectangle.portrait.and.arrow.right", role: .destructive) { + showsSignOutConfirmation = true + } + } label: { + HStack(spacing: FlixySpacing.snug) { + avatar + + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.flixyCardTitle) + .foregroundStyle(Color.flixyTextPrimary) + .lineLimit(1) + + if let subtitle { + Text(subtitle) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .lineLimit(1) + } + } + + Spacer(minLength: FlixySpacing.snug) + + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundStyle(Color.flixyTextMuted) + } + .padding(.vertical, FlixySpacing.tight) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(title)) + .confirmationDialog( + Text("settings.signOutConfirm"), + isPresented: $showsSignOutConfirmation, + titleVisibility: .visible + ) { + Button("action.signOut", role: .destructive) { session.signOut() } + Button("action.cancel", role: .cancel) {} + } message: { + Text("settings.signOutExplanation") + } + } + + /// Der erste Buchstabe auf einer Scheibe. + /// + /// Jellyfin gibt kein Profilbild heraus, ohne dass eines hinterlegt ist, + /// und Plex kennt den Kontonamen am Server gar nicht. Ein Buchstabe ist + /// ehrlicher als ein graues Platzhalterbild. + private var avatar: some View { + Text(title.prefix(1).uppercased()) + .font(.flixyCaption.weight(.semibold)) + .foregroundStyle(Color.flixyCanvas) + .frame(width: 26, height: 26) + .background(accent, in: .circle) + } +} diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 7cab607..9a576e5 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -218,6 +218,12 @@ struct RootView: View { } .tabViewStyle(.sidebarAdaptable) .tabViewCustomization($customization) + // Das Konto sitzt unten in der Seitenleiste, nicht als Eintrag + // zwischen den Mediatheken. Dort sucht man es. + .tabViewSidebarBottomBar { + AccountBar(selection: $selection) + .padding(.horizontal, FlixySpacing.snug) + } .miniPlayer(player, isVisible: playback.presentation == .mini) } diff --git a/Flixy/Views/Settings/LanguagesView.swift b/Flixy/Views/Settings/LanguagesView.swift new file mode 100644 index 0000000..e8da393 --- /dev/null +++ b/Flixy/Views/Settings/LanguagesView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import FlixyDesign + +/// Welche Sprachen der Player von sich aus wählen soll. +/// +/// Die Einstellung gab es schon — der Player liest sie bei jeder Wiedergabe +/// aus und wählt die passende Spur. Nur einstellen konnte man sie nirgends: +/// sie stand in den Voreinstellungen und blieb dort für immer leer. Eine +/// Vorliebe, die niemand äußern kann, ist keine. +struct LanguagesView: View { + @Environment(PlaybackSettings.self) private var settings + @Environment(\.flixyAccent) private var accent + + /// Die Sprachen, die in Mediatheken tatsächlich vorkommen. + /// + /// Keine vollständige Liste aller Sprachen der Welt: ein Auswahlrad mit + /// zweihundert Einträgen, um „Deutsch" zu finden, ist keine Hilfe. Was + /// fehlt, lässt sich später ergänzen — dann mit einem Suchfeld. + private static let codes = [ + "de", "en", "fr", "es", "it", "nl", "pl", "pt", "ru", "sv", "da", "tr", + "ja", "ko", "zh", + ] + + var body: some View { + @Bindable var settings = settings + + Form { + Section { + Picker("languages.audio", selection: $settings.preferredAudioLanguage) { + Text("languages.serverDefault").tag("") + ForEach(Self.codes, id: \.self) { code in + Text(name(of: code)).tag(code) + } + } + } header: { + Text("languages.audioHeader") + } footer: { + Text("languages.audioExplanation") + } + + Section { + Picker("languages.subtitles", selection: $settings.preferredSubtitleLanguage) { + Text("languages.subtitlesOff").tag("") + ForEach(Self.codes, id: \.self) { code in + Text(name(of: code)).tag(code) + } + } + } header: { + Text("languages.subtitlesHeader") + } footer: { + Text("languages.subtitlesExplanation") + } + } + .tint(accent) + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("languages.title")) + .navigationBarTitleDisplayMode(.inline) + } + + /// Der Name der Sprache in der Sprache des Geräts. + private func name(of code: String) -> String { + Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased() + } +} diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 15a2440..e58fae6 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -86,6 +86,12 @@ struct SettingsView: View { Label("appearance.title", systemImage: "paintpalette") } + NavigationLink { + LanguagesView() + } label: { + Label("languages.title", systemImage: "captions.bubble") + } + NavigationLink { HomeLayoutView() } label: { diff --git a/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings b/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings index 9043eae..13fba64 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings +++ b/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings @@ -1,150 +1,151 @@ { - "sourceLanguage": "en", - "version": "1.0", - "strings": { - "player.transcodeUnavailable": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Server hat keine umgerechnete Fassung angeboten." + "sourceLanguage" : "en", + "strings" : { + "player.transcodeUnavailable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Server hat keine umgerechnete Fassung angeboten." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The server didn't offer a transcoded version." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server didn't offer a transcoded version." } } } }, - "row.continueWatching": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Weiterschauen" + "row.continueWatching" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weiterschauen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Continue watching" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue watching" } } } }, - "row.favourites": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Favoriten" + "row.favourites" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoriten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Favourites" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favourites" } } } }, - "row.latestIn %@": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Neu in %@" + "row.latestIn %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neu in %@" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "New in %@" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New in %@" } } } }, - "row.nextUp": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Als Nächstes" + "row.nextUp" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als Nächstes" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Next up" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next up" } } } }, - "row.recentlyAdded": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zuletzt hinzugefügt" + "row.recentlyAdded" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt hinzugefügt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Recently added" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recently added" } } } }, - "search.episodes": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Folgen" + "search.episodes" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folgen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Episodes" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Episodes" } } } }, - "search.movies": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Filme" + "search.movies" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filme" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Movies" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Movies" } } } }, - "search.shows": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Serien" + "search.shows" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serien" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Shows" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows" } } } } - } -} + }, + "version" : "1.0" +} \ No newline at end of file