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