diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 71d05db..4f33235 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -33,7 +33,24 @@ "comment": "A label for a 720p movie.", "isCommentAutoGenerated": true }, + "account.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnet Einstellungen und Abmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Opens settings and sign out" + } + } + } + }, "account.settings": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1813,6 +1830,22 @@ } } }, + "home.combineContinue": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiterschauen und Als Nächstes zusammenlegen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Merge Continue and Next Up" + } + } + } + }, "home.empty.description": { "localizations": { "de": { @@ -1846,6 +1879,134 @@ } } }, + "home.hero": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufmacher" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Featured" + } + } + } + }, + "home.heroCount": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel im Wechsel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Titles in rotation" + } + } + } + }, + "home.heroExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Aufmacher wandert alle sieben Sekunden weiter und hört damit auf, sobald du ihn berührst." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The featured area moves on every seven seconds and stops as soon as you touch it." + } + } + } + }, + "home.heroSource": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Inhalt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Content" + } + } + } + }, + "home.heroSource.continueFirst": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Angefangenes zuerst" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Started titles first" + } + } + } + }, + "home.heroSource.latestOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Neuzugänge" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "New arrivals only" + } + } + } + }, + "home.hideWatched": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesehenes aus Neuzugängen ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide watched titles from new arrivals" + } + } + } + }, + "home.rowsHeader": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Reihen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rows" + } + } + } + }, "homeLayout.empty": { "localizations": { "de": { @@ -1911,6 +2072,7 @@ } }, "languages.audio": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1975,6 +2137,7 @@ } }, "languages.subtitles": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -3606,6 +3769,54 @@ } } }, + "settings.concurrent": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Höchstens gleichzeitig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "At most at once" + } + } + } + }, + "settings.concurrentExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eine ganze Staffel auf einmal zu laden teilt die Leitung so auf, dass keine Folge fertig wird. Unterwegs ist eine fertige Folge mehr wert als zehn halbe." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading a whole season at once splits the line so that no episode finishes. On the move, one finished episode beats ten half ones." + } + } + } + }, + "settings.concurrentHeader": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gleichzeitige Downloads" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Concurrent downloads" + } + } + } + }, "settings.connection": { "extractionState": "stale", "localizations": { @@ -3674,6 +3885,54 @@ } } }, + "settings.controls": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Steuerung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Controls" + } + } + } + }, + "settings.controlsExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück und vor lassen sich getrennt einstellen: zurück springt man, weil man etwas nicht verstanden hat — vor, weil man etwas überspringen will." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back and forward are set separately: you jump back because you missed something, and forward because you want to skip something." + } + } + } + }, + "settings.defaultRate": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Startgeschwindigkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting speed" + } + } + } + }, "settings.deleteAfterDay": { "localizations": { "de": { @@ -3771,6 +4030,38 @@ } } }, + "settings.episodes": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Folgen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Episodes" + } + } + } + }, + "settings.forward": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorspulen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fast-forward" + } + } + } + }, "settings.keepCount %@": { "comment": "A label that lets the user choose how many episodes of a series to keep after they've finished watching them all. The argument is the number of episodes to keep.", "isCommentAutoGenerated": true @@ -3841,6 +4132,38 @@ } } }, + "settings.lockLandscape": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Querformat sperren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lock to landscape" + } + } + } + }, + "settings.lockLandscapeExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Player dreht beim Öffnen ins Querformat und bleibt dort. Abgeschaltet folgt er dem Gerät — für den, der im Liegen schaut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The player rotates to landscape when it opens and stays there. Switched off it follows the device — for watching while lying down." + } + } + } + }, "settings.playback": { "extractionState": "stale", "localizations": { @@ -3924,6 +4247,70 @@ } } }, + "settings.rateNormal": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + } + } + }, + "settings.rememberQuality": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gewählte Qualität merken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remember the chosen quality" + } + } + } + }, + "settings.rememberQualityExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die im Player gewählte Stufe gilt dann auch für den nächsten Titel. Gibt es sie dort nicht, wird die nächstgelegene genommen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The step you pick in the player then applies to the next title as well. If it isn't available there, the closest one is used." + } + } + } + }, + "settings.rewind": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurückspulen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rewind" + } + } + } + }, "settings.scanExplanation": { "localizations": { "de": { @@ -3940,6 +4327,54 @@ } } }, + "settings.seconds %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Sekunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld seconds" + } + } + } + }, + "settings.section.look": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Darstellung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + } + } + }, + "settings.section.playing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wiedergabe & Offline" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Playback & offline" + } + } + } + }, "settings.server": { "extractionState": "stale", "localizations": { @@ -3991,6 +4426,22 @@ } } }, + "settings.showBitrate": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bitrate im Qualitätsmenü zeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show the bitrate in the quality menu" + } + } + } + }, "settings.signOutConfirm": { "localizations": { "de": { diff --git a/Flixy/Session/HomeLayoutSettings.swift b/Flixy/Session/HomeLayoutSettings.swift index 2560d36..642ced5 100644 --- a/Flixy/Session/HomeLayoutSettings.swift +++ b/Flixy/Session/HomeLayoutSettings.swift @@ -15,17 +15,51 @@ final class HomeLayoutSettings { private enum Key { static let hidden = "flixy.home.hiddenHubs" static let order = "flixy.home.hubOrder" + static let heroCount = "flixy.home.heroCount" + static let heroSource = "flixy.home.heroSource" + static let combinesContinueRows = "flixy.home.combinesContinueRows" + static let hidesWatchedInLatest = "flixy.home.hidesWatchedInLatest" } + /// Woraus sich der Aufmacher speist. + enum HeroSource: String, CaseIterable, Identifiable, Sendable { + /// Angefangenes zuerst, danach aufgefüllt. + case continueFirst + /// Nur Neuzugänge — für den, der den Aufmacher als Schaufenster + /// versteht und nicht als Fortsetzung. + case latestOnly + + var id: String { rawValue } + } + + /// Wie viele Titel der Aufmacher durchwechselt. + static let heroCountChoices = [1, 3, 5, 7, 10] + private let defaults: UserDefaults private(set) var arrangement: Arrangement + var heroCount: Int { didSet { defaults.set(heroCount, forKey: Key.heroCount) } } + var heroSource: HeroSource { didSet { defaults.set(heroSource.rawValue, forKey: Key.heroSource) } } + /// Ob „Weiterschauen" und „Als Nächstes" eine Reihe sind. + /// + /// Getrennt ist genauer, zusammen ist kürzer. Beides ist vertretbar, und + /// welches richtig ist, hängt davon ab, wie viele Serien jemand parallel + /// schaut. + var combinesContinueRows: Bool { didSet { defaults.set(combinesContinueRows, forKey: Key.combinesContinueRows) } } + /// Ob Gesehenes aus „Zuletzt hinzugefügt" verschwindet. + var hidesWatchedInLatest: Bool { didSet { defaults.set(hidesWatchedInLatest, forKey: Key.hidesWatchedInLatest) } } + init(defaults: UserDefaults = .standard) { self.defaults = defaults self.arrangement = Arrangement( hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []), order: defaults.stringArray(forKey: Key.order) ?? [] ) + self.heroCount = defaults.object(forKey: Key.heroCount) as? Int ?? 5 + self.heroSource = defaults.string(forKey: Key.heroSource) + .flatMap(HeroSource.init(rawValue:)) ?? .continueFirst + self.combinesContinueRows = defaults.object(forKey: Key.combinesContinueRows) as? Bool ?? false + self.hidesWatchedInLatest = defaults.object(forKey: Key.hidesWatchedInLatest) as? Bool ?? false } func isHidden(_ identifier: String) -> Bool { diff --git a/Flixy/Session/OrientationLock.swift b/Flixy/Session/OrientationLock.swift index ade29ac..1150ca8 100644 --- a/Flixy/Session/OrientationLock.swift +++ b/Flixy/Session/OrientationLock.swift @@ -55,16 +55,22 @@ final class OrientationLock { /// Dreht beim Erscheinen ins Querformat und beim Verschwinden zurück. struct LandscapeWhileVisible: ViewModifier { + /// Ob überhaupt gesperrt wird. + /// + /// Für die allermeisten Filme ist das Querformat richtig — und für den, + /// der im Bett auf der Seite liegt, das Gegenteil davon. + let isEnabled: Bool + func body(content: Content) -> some View { content - .onAppear { OrientationLock.shared.lockLandscape() } + .onAppear { if isEnabled { OrientationLock.shared.lockLandscape() } } .onDisappear { OrientationLock.shared.releaseLandscape() } } } extension View { /// Für den Vollbildplayer: Film ist Querformat. - func landscapeWhileVisible() -> some View { - modifier(LandscapeWhileVisible()) + func landscapeWhileVisible(_ isEnabled: Bool = true) -> some View { + modifier(LandscapeWhileVisible(isEnabled: isEnabled)) } } diff --git a/Flixy/Session/PlaybackSettings.swift b/Flixy/Session/PlaybackSettings.swift index e3aa9f7..b88b1c9 100644 --- a/Flixy/Session/PlaybackSettings.swift +++ b/Flixy/Session/PlaybackSettings.swift @@ -35,8 +35,24 @@ final class PlaybackSettings { static let autoPlayNext = "flixy.playback.autoPlayNext" static let preferredAudioLanguage = "flixy.playback.audioLanguage" static let preferredSubtitleLanguage = "flixy.playback.subtitleLanguage" + static let locksLandscape = "flixy.playback.locksLandscape" + static let rewindSeconds = "flixy.playback.rewindSeconds" + static let forwardSeconds = "flixy.playback.forwardSeconds" + static let defaultRate = "flixy.playback.defaultRate" + static let remembersQuality = "flixy.playback.remembersQuality" + static let rememberedQuality = "flixy.playback.rememberedQuality" + static let showsBitrate = "flixy.playback.showsBitrate" } + /// Die Sprünge, die es zur Auswahl gibt. + /// + /// Nicht frei einstellbar: ein Schieberegler für Sekunden lädt dazu ein, + /// 23 einzustellen, und dafür gibt es kein Symbol. Diese fünf haben eins. + static let skipChoices = [5, 10, 15, 30, 45] + + /// Die Geschwindigkeiten, mit denen eine Wiedergabe beginnen kann. + static let rateChoices: [Double] = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0] + private let defaults: UserDefaults var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } } @@ -48,6 +64,37 @@ final class PlaybackSettings { var preferredAudioLanguage: String { didSet { defaults.set(preferredAudioLanguage, forKey: Key.preferredAudioLanguage) } } var preferredSubtitleLanguage: String { didSet { defaults.set(preferredSubtitleLanguage, forKey: Key.preferredSubtitleLanguage) } } + /// Ob der Player beim Öffnen ins Querformat dreht und dort bleibt. + /// + /// Für die allermeisten Filme richtig — und für den, der im Bett auf der + /// Seite liegt, das Gegenteil davon. Deshalb abschaltbar. + var locksLandscape: Bool { didSet { defaults.set(locksLandscape, forKey: Key.locksLandscape) } } + /// Wie weit der Rücksprung geht, in Sekunden. + var rewindSeconds: Int { didSet { defaults.set(rewindSeconds, forKey: Key.rewindSeconds) } } + /// Wie weit der Vorsprung geht. Getrennt vom Rücksprung, weil die beiden + /// verschiedene Zwecke haben: zurück, weil man etwas nicht verstanden hat; + /// vor, weil man etwas überspringen will. + var forwardSeconds: Int { didSet { defaults.set(forwardSeconds, forKey: Key.forwardSeconds) } } + /// Mit welcher Geschwindigkeit eine Wiedergabe beginnt. + var defaultRate: Double { didSet { defaults.set(defaultRate, forKey: Key.defaultRate) } } + /// Ob die im Player gewählte Stufe für den nächsten Titel gilt. + var remembersQuality: Bool { didSet { defaults.set(remembersQuality, forKey: Key.remembersQuality) } } + /// Ob im Qualitätsmenü steht, was gerade tatsächlich ankommt. + var showsBitrate: Bool { didSet { defaults.set(showsBitrate, forKey: Key.showsBitrate) } } + + /// Die zuletzt gewählte Stufe, als Zahl. + /// + /// Hier und nicht im Player: der lebt nur so lange wie ein Film. Was über + /// mehrere Filme hinweg gelten soll, gehört in die Einstellungen. + var rememberedQuality: Int? { + get { + guard remembersQuality else { return nil } + let value = defaults.integer(forKey: Key.rememberedQuality) + return value == 0 ? nil : value + } + set { defaults.set(newValue ?? 0, forKey: Key.rememberedQuality) } + } + init(defaults: UserDefaults = .standard) { self.defaults = defaults @@ -62,6 +109,13 @@ final class PlaybackSettings { self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true self.preferredAudioLanguage = defaults.string(forKey: Key.preferredAudioLanguage) ?? "" self.preferredSubtitleLanguage = defaults.string(forKey: Key.preferredSubtitleLanguage) ?? "" + + self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true + self.rewindSeconds = defaults.object(forKey: Key.rewindSeconds) as? Int ?? 10 + self.forwardSeconds = defaults.object(forKey: Key.forwardSeconds) as? Int ?? 10 + self.defaultRate = defaults.object(forKey: Key.defaultRate) as? Double ?? 1 + self.remembersQuality = defaults.object(forKey: Key.remembersQuality) as? Bool ?? false + self.showsBitrate = defaults.object(forKey: Key.showsBitrate) as? Bool ?? true } /// Die Richtlinie für die aktuelle Lage. diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 6f3a4f1..11e86c6 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -24,7 +24,7 @@ struct HomeView: View { } case .loaded(let allRows): - let hubs = layout.apply(to: allRows) + let hubs = layout.apply(to: prepared(allRows)) if hubs.isEmpty { ContentUnavailableView { Label("home.empty.title", systemImage: "film.stack") @@ -72,26 +72,83 @@ struct HomeView: View { .scrollIndicators(.hidden) } - /// Wie viele Titel der Aufmacher durchwechselt. - private static let heroCount = 5 + /// Bringt die Reihen vom Server in die Form, die eingestellt ist. + /// + /// Hier und nicht im Backend: „Weiterschauen und Als Nächstes zusammen" + /// und „Gesehenes ausblenden" sind Fragen der Darstellung, keine des + /// Servers. Beides hier zu erledigen hält den Vertrag schmal und gilt für + /// Plex und Jellyfin gleichermaßen. + private func prepared(_ rows: [MediaRowGroup]) -> [MediaRowGroup] { + var rows = rows + + if layout.hidesWatchedInLatest { + rows = rows.map { row in + guard row.id.hasPrefix("latest") else { return row } + return MediaRowGroup( + id: row.id, + title: row.title, + moreKey: row.moreKey, + items: row.items.filter { !$0.isWatched } + ) + } + } + + if layout.combinesContinueRows { + rows = combiningContinueRows(rows) + } + + return rows.filter { !$0.items.isEmpty } + } + + /// Legt „Weiterschauen" und „Als Nächstes" in eine Reihe. + /// + /// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre + /// sie für die gespeicherte Reihenfolge eine neue und stünde plötzlich + /// wieder ganz unten. + private func combiningContinueRows(_ rows: [MediaRowGroup]) -> [MediaRowGroup] { + let continuing = rows.filter(\.isContinueWatching) + guard continuing.count > 1, let first = continuing.first else { return rows } + + var seen = Set() + let merged = continuing.flatMap(\.items).filter { seen.insert($0.id).inserted } + + var result: [MediaRowGroup] = [] + for row in rows { + guard row.isContinueWatching else { + result.append(row) + continue + } + guard row.id == first.id else { continue } + result.append(MediaRowGroup(id: row.id, title: row.title, items: merged)) + } + return result + } /// Was oben groß gezeigt wird. /// /// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen. /// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen - /// Folge gäbe es sonst nichts zu blättern. + /// Folge gäbe es sonst nichts zu blättern. Wer den Aufmacher lieber als + /// Schaufenster versteht, stellt auf Neuzugänge um. private func heroItems(in hubs: [MediaRowGroup]) -> [MediaItem] { - let continuing = hubs.first(where: \.isContinueWatching)?.items ?? [] - let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items) + let candidates: [MediaItem] + switch layout.heroSource { + case .continueFirst: + let continuing = hubs.first(where: \.isContinueWatching)?.items ?? [] + let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items) + candidates = continuing + rest + case .latestOnly: + candidates = hubs.filter { $0.id.hasPrefix("latest") }.flatMap(\.items) + } var seen = Set() - let candidates = (continuing + rest).filter { seen.insert($0.id).inserted } + let unique = candidates.filter { seen.insert($0.id).inserted } // Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text. // Lieber einen Titel überspringen als eine leere Bühne zeigen — es sei // denn, keiner hätte eines. - let withArtwork = candidates.filter { $0.art != nil || $0.seriesArt != nil } - return Array((withArtwork.isEmpty ? candidates : withArtwork).prefix(Self.heroCount)) + let withArtwork = unique.filter { $0.art != nil || $0.seriesArt != nil } + return Array((withArtwork.isEmpty ? unique : withArtwork).prefix(layout.heroCount)) } private func load() async { diff --git a/Flixy/Views/Navigation/AccountBar.swift b/Flixy/Views/Navigation/AccountBar.swift index 9c8cbba..129df20 100644 --- a/Flixy/Views/Navigation/AccountBar.swift +++ b/Flixy/Views/Navigation/AccountBar.swift @@ -3,18 +3,25 @@ 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. +/// Der Platz ist nicht zufällig: dort sucht man sein Konto, weil es 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. +/// **Eine Karte, die nach oben aufklappt**, kein Systemmenü. Der Unterschied +/// ist nicht nur Geschmack: ein Menü legt sich als eigene Ebene über alles und +/// springt dorthin, wo Platz ist. Die Karte bleibt, wo sie ist, wächst nach +/// oben und lässt die Zeile mit dem Namen die ganze Zeit stehen — man sieht +/// also weiter, um wessen Konto es geht. +/// +/// Auf dem iPhone gibt es diese Leiste nicht; dort haben die Einstellungen +/// ihren eigenen Eintrag. struct AccountBar: View { @Binding var selection: SidebarDestination @Environment(FlixySession.self) private var session @Environment(\.flixyAccent) private var accent + @State private var isExpanded = false @State private var showsSignOutConfirmation = false private var title: String { @@ -28,16 +35,44 @@ struct AccountBar: View { } var body: some View { - Menu { - Button("account.settings", systemImage: "gearshape") { - selection = .settings + VStack(spacing: 0) { + if isExpanded { + action("account.settings", symbol: "gearshape") { + collapse() + selection = .settings + } + separator + + action( + "action.signOut", + symbol: "rectangle.portrait.and.arrow.right", + isDestructive: true + ) { + collapse() + showsSignOutConfirmation = true + } + separator } - Divider() + accountRow + } + .glassEffect(.regular, in: .rect(cornerRadius: FlixyRadius.sheet)) + .animation(.snappy(duration: 0.25), value: isExpanded) + .confirmationDialog( + Text("settings.signOutConfirm"), + isPresented: $showsSignOutConfirmation, + titleVisibility: .visible + ) { + Button("action.signOut", role: .destructive) { session.signOut() } + Button("action.cancel", role: .cancel) {} + } message: { + Text("settings.signOutExplanation") + } + } - Button("action.signOut", systemImage: "rectangle.portrait.and.arrow.right", role: .destructive) { - showsSignOutConfirmation = true - } + private var accountRow: some View { + Button { + withAnimation(.snappy(duration: 0.25)) { isExpanded.toggle() } } label: { HStack(spacing: FlixySpacing.snug) { avatar @@ -58,25 +93,40 @@ struct AccountBar: View { Spacer(minLength: FlixySpacing.snug) - Image(systemName: "chevron.up.chevron.down") - .font(.caption2) + // Zeigt nach oben, wenn zugeklappt: dorthin wächst die Karte. + Image(systemName: isExpanded ? "chevron.down" : "chevron.up") + .font(.caption2.weight(.semibold)) .foregroundStyle(Color.flixyTextMuted) } - .padding(.vertical, FlixySpacing.tight) + .padding(.horizontal, FlixySpacing.regular) + .padding(.vertical, FlixySpacing.snug) .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") + .accessibilityHint(Text("account.hint")) + } + + private func action( + _ title: LocalizedStringKey, + symbol: String, + isDestructive: Bool = false, + perform: @escaping () -> Void + ) -> some View { + Button(action: perform) { + Label(title, systemImage: symbol) + .font(.flixyBody) + .foregroundStyle(isDestructive ? Color.flixyDestructive : Color.flixyTextPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, FlixySpacing.regular) + .padding(.vertical, FlixySpacing.regular) + .contentShape(.rect) } + .buttonStyle(.plain) + } + + private var separator: some View { + Divider().overlay(Color.flixySeparator) } /// Der erste Buchstabe auf einer Scheibe. @@ -91,4 +141,8 @@ struct AccountBar: View { .frame(width: 26, height: 26) .background(accent, in: .circle) } + + private func collapse() { + withAnimation(.snappy(duration: 0.25)) { isExpanded = false } + } } diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index a7f3dee..283d73b 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -218,7 +218,18 @@ final class PlayerModel { currentMedia = media currentPart = part - let decision = PlaybackDecider().decide(profile: media.profile(file: part), policy: policy) + // Die zuletzt gewählte Stufe, falls sie gemerkt werden soll. **Vor** + // der Entscheidung, nicht danach: hinterher umzustellen hieße, den + // Strom zweimal aufzubauen und den Anfang zweimal zu puffern. + if let stored = settings.rememberedQuality, + let restored = PlayerQuality.restored(from: stored, forSourceHeight: media.height) { + quality = restored + } + + let decision = PlaybackDecider().decide( + profile: media.profile(file: part), + policy: quality.policy(basedOn: policy) + ) self.decision = decision guard let source = await source(for: decision, item: item, media: media, part: part, backend: backend) else { @@ -253,6 +264,9 @@ final class PlayerModel { let resumeAt = currentTime resumeTarget = resumeAt quality = newQuality + // Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen + // einen Film. + if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue } // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet // der Server zwei Streams gleichzeitig für einen Zuschauer. @@ -374,6 +388,9 @@ final class PlayerModel { diagnostics.note("Engine öffnet Quelle") try await engine.load(source) engine.play() + // Erst nach dem Start: vorher hat die Engine noch keinen Gegenstand, + // dessen Geschwindigkeit sich setzen ließe. + if settings.defaultRate != 1 { engine.setRate(settings.defaultRate) } phase = .playing diagnostics.note("Engine gestartet") // Weder offline noch live gibt es einen Fortschritt zu melden. diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 77090e3..d927ebe 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -103,7 +103,7 @@ struct PlayerView: View { .animation(.easeInOut(duration: 0.2), value: model.isBuffering) .statusBarHidden(true) .persistentSystemOverlays(showsControls ? .automatic : .hidden) - .landscapeWhileVisible() + .landscapeWhileVisible(settings.locksLandscape) .contentShape(.rect) // Für alles außerhalb der Sprungflächen: Tippen zeigt und verbirgt // die Bedienelemente wie bisher. @@ -230,10 +230,10 @@ struct PlayerView: View { private func transport(for model: PlayerModel) -> some View { HStack(spacing: FlixySpacing.section * 2) { Button { - model.skip(by: .seconds(-10)) + model.skip(by: .seconds(-settings.rewindSeconds)) scheduleControlsHide() } label: { - Image(systemName: "gobackward.10").font(.largeTitle) + Image(systemName: "gobackward.\(settings.rewindSeconds)").font(.largeTitle) } .accessibilityLabel(Text("player.back10")) @@ -247,10 +247,10 @@ struct PlayerView: View { .accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play")) Button { - model.skip(by: .seconds(10)) + model.skip(by: .seconds(settings.forwardSeconds)) scheduleControlsHide() } label: { - Image(systemName: "goforward.10").font(.largeTitle) + Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle) } .accessibilityLabel(Text("player.forward10")) } @@ -411,9 +411,15 @@ struct PlayerView: View { let height = geometry.size.height * 0.45 HStack(spacing: 0) { - seekZone(seconds: -10, symbol: "gobackward.10").frame(width: width) + seekZone( + seconds: Double(-settings.rewindSeconds), + symbol: "gobackward.\(settings.rewindSeconds)" + ).frame(width: width) Spacer(minLength: 0) - seekZone(seconds: 10, symbol: "goforward.10").frame(width: width) + seekZone( + seconds: Double(settings.forwardSeconds), + symbol: "goforward.\(settings.forwardSeconds)" + ).frame(width: width) } .frame(height: height) // Etwas unterhalb der Mitte: oben bleibt die Kopfzeile frei, diff --git a/Flixy/Views/Player/QualitySheet.swift b/Flixy/Views/Player/QualitySheet.swift index d1d1278..e49706c 100644 --- a/Flixy/Views/Player/QualitySheet.swift +++ b/Flixy/Views/Player/QualitySheet.swift @@ -12,6 +12,7 @@ struct QualitySheet: View { let model: PlayerModel @Environment(\.flixyAccent) private var accent + @Environment(PlaybackSettings.self) private var settings @Environment(\.dismiss) private var dismiss @State private var showsEveryRung = false @@ -66,7 +67,7 @@ struct QualitySheet: View { /// Was gerade läuft — gemessen, nicht gewünscht. @ViewBuilder private var current: some View { - if let statistics = model.statistics { + if settings.showsBitrate, let statistics = model.statistics { Section { if let observed = statistics.observedBitrate { LabeledContent("quality.current.bitrate", value: bitrateText(observed)) diff --git a/Flixy/Views/Settings/DownloadSettingsView.swift b/Flixy/Views/Settings/DownloadSettingsView.swift new file mode 100644 index 0000000..60332d9 --- /dev/null +++ b/Flixy/Views/Settings/DownloadSettingsView.swift @@ -0,0 +1,95 @@ +import SwiftUI +import FlixyDesign +import FlixyDownloads +import MediaKit + +/// Was beim Herunterladen gilt. +/// +/// Die Löschfrist steht bewusst mit ausgeschriebenen Fristen da statt mit +/// einem Schieberegler: „Nach 7 Tagen" ist eine Entscheidung, die man bewusst +/// trifft — beim Löschen von Filmen soll niemand versehentlich auf „sofort" +/// rutschen. +struct DownloadSettingsView: View { + @Environment(DownloadCoordinator.self) private var downloads + @Environment(\.flixyAccent) private var accent + + var body: some View { + @Bindable var downloads = downloads + + Form { + Section { + Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly) + + Picker("settings.storageLimit", selection: storageLimitBinding) { + Text("settings.storageUnlimited").tag(Int64(0)) + ForEach([Int64(5), 10, 20, 30, 50, 100], id: \.self) { gigabytes in + Text("\(gigabytes) GB").tag(gigabytes * 1_000_000_000) + } + } + } footer: { + Text("settings.downloadsExplanation") + } + + Section { + Stepper(value: $downloads.maximumConcurrentDownloads, in: 1...5) { + LabeledContent( + "settings.concurrent", + value: downloads.maximumConcurrentDownloads.formatted() + ) + } + } header: { + Text("settings.concurrentHeader") + } footer: { + // Zwei statt aller auf einmal: eine ganze Staffel parallel zu + // laden teilt die Bandbreite so auf, dass keine Folge fertig + // wird — und unterwegs ist eine fertige Folge mehr wert als + // zehn halbe. + Text("settings.concurrentExplanation") + } + + Section { + Picker("settings.deleteWatched", selection: deleteAfterBinding) { + Text("settings.deleteNever").tag(Double(-1)) + Text("settings.deleteImmediately").tag(Double(0)) + Text("settings.deleteAfterDay").tag(Duration.afterOneDay.seconds) + Text("settings.deleteAfterWeek").tag(Duration.afterOneWeek.seconds) + } + + Picker("settings.keepUnwatched", selection: keepUnwatchedBinding) { + Text("settings.keepOff").tag(0) + ForEach([1, 2, 3, 5], id: \.self) { count in + Text("settings.keepCount \(count)").tag(count) + } + } + } header: { + Text("settings.episodes") + } + } + .tint(accent) + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("sidebar.downloads")) + .navigationBarTitleDisplayMode(.inline) + } + + private var storageLimitBinding: Binding { + Binding( + get: { downloads.storageLimitBytes ?? 0 }, + set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil } + ) + } + + private var deleteAfterBinding: Binding { + Binding( + get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 }, + set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil } + ) + } + + private var keepUnwatchedBinding: Binding { + Binding( + get: { downloads.rules.keepUnwatchedEpisodes ?? 0 }, + set: { downloads.rules.keepUnwatchedEpisodes = $0 > 0 ? $0 : nil } + ) + } +} diff --git a/Flixy/Views/Settings/HomeLayoutView.swift b/Flixy/Views/Settings/HomeLayoutView.swift index 6b0d700..17f1f2d 100644 --- a/Flixy/Views/Settings/HomeLayoutView.swift +++ b/Flixy/Views/Settings/HomeLayoutView.swift @@ -12,19 +12,7 @@ struct HomeLayoutView: View { @State private var isLoading = true var body: some View { - Group { - if isLoading { - ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) - } else if rows.isEmpty { - ContentUnavailableView { - Label("home.empty.title", systemImage: "rectangle.stack") - } description: { - Text("homeLayout.empty") - } - } else { - list - } - } + list .background(Color.flixyCanvas) .navigationTitle(Text("homeLayout.title")) .navigationBarTitleDisplayMode(.inline) @@ -34,6 +22,42 @@ struct HomeLayoutView: View { private var list: some View { List { + @Bindable var layout = layout + + Section { + Picker("home.heroSource", selection: $layout.heroSource) { + ForEach(HomeLayoutSettings.HeroSource.allCases) { source in + Text(source.label).tag(source) + } + } + Picker("home.heroCount", selection: $layout.heroCount) { + ForEach(HomeLayoutSettings.heroCountChoices, id: \.self) { count in + Text(count.formatted()).tag(count) + } + } + } header: { + Text("home.hero") + } footer: { + Text("home.heroExplanation") + } + .listRowBackground(Color.flixySurface) + + Section { + Toggle("home.combineContinue", isOn: $layout.combinesContinueRows) + Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest) + } + .listRowBackground(Color.flixySurface) + + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } else if rows.isEmpty { + Text("homeLayout.empty") + .foregroundStyle(Color.flixyTextSecondary) + .listRowBackground(Color.flixySurface) + } + Section { ForEach($rows) { $row in Toggle(isOn: Binding( @@ -52,6 +76,8 @@ struct HomeLayoutView: View { rows.move(fromOffsets: indices, toOffset: destination) layout.setOrder(rows.map(\.id)) } + } header: { + Text("home.rowsHeader") } footer: { Text("homeLayout.explanation") } @@ -76,3 +102,12 @@ struct HomeLayoutView: View { rows = layout.rows(from: hubs) } } + +extension HomeLayoutSettings.HeroSource { + var label: LocalizedStringKey { + switch self { + case .continueFirst: "home.heroSource.continueFirst" + case .latestOnly: "home.heroSource.latestOnly" + } + } +} diff --git a/Flixy/Views/Settings/PlaybackSettingsView.swift b/Flixy/Views/Settings/PlaybackSettingsView.swift new file mode 100644 index 0000000..44a65f4 --- /dev/null +++ b/Flixy/Views/Settings/PlaybackSettingsView.swift @@ -0,0 +1,105 @@ +import SwiftUI +import FlixyDesign +import FlixyPlayback + +/// Alles, was die Wiedergabe betrifft. +/// +/// Vorher stand das verstreut in der langen Liste der Einstellungen, zwischen +/// Serverangaben und Downloads. Zusammen ergibt es einen Bildschirm, den man +/// einmal durchgeht und dann in Ruhe lässt. +struct PlaybackSettingsView: View { + @Environment(PlaybackSettings.self) private var settings + @Environment(\.flixyAccent) private var accent + + var body: some View { + @Bindable var settings = settings + + Form { + Section { + Picker("settings.qualityWifi", selection: $settings.wifiQuality) { + ForEach(PlaybackSettings.Quality.allCases) { quality in + Text(quality.label).tag(quality) + } + } + Picker("settings.qualityCellular", selection: $settings.cellularQuality) { + ForEach(PlaybackSettings.Quality.allCases) { quality in + Text(quality.label).tag(quality) + } + } + } header: { + Text("settings.quality") + } footer: { + Text("settings.qualityExplanation") + } + + Section { + Toggle("settings.rememberQuality", isOn: $settings.remembersQuality) + Toggle("settings.showBitrate", isOn: $settings.showsBitrate) + } footer: { + Text("settings.rememberQualityExplanation") + } + + Section { + Picker("settings.rewind", selection: $settings.rewindSeconds) { + ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in + Text("settings.seconds \(seconds)").tag(seconds) + } + } + Picker("settings.forward", selection: $settings.forwardSeconds) { + ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in + Text("settings.seconds \(seconds)").tag(seconds) + } + } + Picker("settings.defaultRate", selection: $settings.defaultRate) { + ForEach(PlaybackSettings.rateChoices, id: \.self) { rate in + Text(rateLabel(rate)).tag(rate) + } + } + } header: { + Text("settings.controls") + } footer: { + // Zurück und vor getrennt, weil sie verschiedene Zwecke haben: + // zurück, weil man etwas nicht verstanden hat; vor, weil man + // etwas überspringen will. + Text("settings.controlsExplanation") + } + + Section { + Toggle("settings.lockLandscape", isOn: $settings.locksLandscape) + } footer: { + Text("settings.lockLandscapeExplanation") + } + + Section { + Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro) + Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits) + Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext) + } header: { + Text("settings.episodes") + } + } + .tint(accent) + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("settings.playback")) + .navigationBarTitleDisplayMode(.inline) + } + + private func rateLabel(_ rate: Double) -> String { + rate == 1 + ? String(localized: "settings.rateNormal") + : rate.formatted(.number.precision(.fractionLength(0...2))) + "×" + } +} + +extension PlaybackSettings.Quality { + var label: LocalizedStringKey { + switch self { + case .original: "quality.original" + case .high: "quality.high" + case .medium: "quality.medium" + case .low: "quality.low" + case .minimal: "quality.minimal" + } + } +} diff --git a/Flixy/Views/Settings/ServerSettingsView.swift b/Flixy/Views/Settings/ServerSettingsView.swift new file mode 100644 index 0000000..25f2c61 --- /dev/null +++ b/Flixy/Views/Settings/ServerSettingsView.swift @@ -0,0 +1,151 @@ +import SwiftUI +import FlixyDesign +import MediaKit +import PlexKit + +/// Mit welchem Server gesprochen wird — und was er gerade anbietet. +struct ServerSettingsView: View { + @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent + + var body: some View { + Form { + Section("settings.server") { + LabeledContent("settings.serverName", value: session.serverName) + // Welche Art von Server — bei zwei möglichen ist das keine + // Nebensache mehr, sondern die erste Frage bei jeder + // Rückmeldung. + LabeledContent("settings.serverKind", value: kindLabel) + if let connection = session.server?.connection { + LabeledContent("settings.connection", value: connectionLabel(connection)) + } + if let address = session.serverAddress { + LabeledContent("settings.address", value: address) + .font(.flixyCaption) + .textSelection(.enabled) + } + if let accountName = session.accountName { + LabeledContent("settings.account", value: accountName) + } + } + + Section { + ForEach(session.libraries) { library in + LibraryRow(library: library) + } + } header: { + Text("settings.libraries") + } footer: { + // Der Suchlauf stand einmal im Dreipunktmenü der Mediathek — + // einen Tipp neben „Filter" und „Ansicht wechseln". Er + // beschäftigt den Server minutenlang und gehört nicht in eine + // Reihe mit Dingen, die man im Vorbeigehen antippt. + if session.backend?.capabilities.contains(.libraryScan) == true { + Text("settings.scanExplanation") + } + } + } + .tint(accent) + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("settings.server")) + .navigationBarTitleDisplayMode(.inline) + } + + private var kindLabel: String { + switch session.kind { + case .plex: "Plex" + case .jellyfin: "Jellyfin" + case nil: "—" + } + } + + /// 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") + } +} + +/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben. +struct LibraryRow: View { + let library: MediaLibrary + + @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent + @State private var phase: Phase = .idle + + private enum Phase { case idle, running, done } + + var body: some View { + LabeledContent { + HStack(spacing: FlixySpacing.regular) { + // Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens + // „Filme" stand sonst als „Filme — Filme" da. + if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame { + Text(library.kind.label) + .foregroundStyle(Color.flixyTextSecondary) + } + + if session.backend?.capabilities.contains(.libraryScan) == true { + Button { + Task { await scan() } + } label: { + symbol + } + .buttonStyle(.borderless) + .disabled(phase == .running) + .accessibilityLabel(Text("library.scan")) + } + } + } label: { + Text(library.title) + } + } + + @ViewBuilder + private var symbol: some View { + switch phase { + case .idle: + Image(systemName: "arrow.triangle.2.circlepath") + case .running: + ProgressView().controlSize(.small) + case .done: + Image(systemName: "checkmark").foregroundStyle(accent) + } + } + + /// Stößt einen Suchlauf auf dem Server an. + /// + /// Der Server meldet nicht, wann er fertig ist. Deshalb steht hier kein + /// Fortschritt, sondern nur ein Haken dafür, dass der Auftrag angekommen + /// ist — ein Balken, der etwas anderes behauptet, wäre erfunden. + private func scan() async { + guard let backend = session.backend, phase != .running else { return } + phase = .running + try? await backend.refreshLibrary(library) + phase = .done + try? await Task.sleep(for: .seconds(4)) + phase = .idle + } +} + +extension MediaLibrary.Kind { + /// Wie diese Art von Mediathek heißt. + /// + /// Übersetzt statt durchgereicht: die Server liefern „movies" und + /// „tvshows", und das großgeschrieben anzuzeigen war eine englische + /// Beschriftung in einer deutschen Oberfläche. + var label: String { + switch self { + case .movie: String(localized: "library.kind.movie") + case .show: String(localized: "library.kind.show") + case .music: String(localized: "library.kind.music") + case .photo: String(localized: "library.kind.photo") + case .liveTV: String(localized: "library.kind.liveTV") + case .other: String(localized: "library.kind.other") + } + } +} diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index e58fae6..577e423 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -1,112 +1,51 @@ import SwiftUI import FlixyDesign -import FlixyDownloads -import FlixyPlayback -import MediaKit -import PlexKit -/// Die Einstellungen. +/// Die Einstellungen — als Übersicht, nicht als eine lange Liste. /// -/// 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. +/// Vorher stand alles untereinander auf einem Bildschirm: Serverangaben, +/// Qualität, Wiedergabe, drei Verweise, Downloads, Version, Abmelden. Das +/// war beim Bauen bequem und beim Suchen anstrengend — je mehr Einstellungen +/// dazukamen, desto weiter rutschte alles nach unten. +/// +/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört. struct SettingsView: View { @Environment(FlixySession.self) private var session @Environment(\.flixyAccent) private var accent - @Environment(PlaybackSettings.self) private var settings - @Environment(DownloadCoordinator.self) private var downloads @State private var showsSignOutConfirmation = false var body: some View { Form { if session.backend != nil { - Section("settings.server") { - LabeledContent("settings.serverName", value: session.serverName) - // Welche Art von Server — bei zwei möglichen ist das keine - // Nebensache mehr, sondern die erste Frage bei jeder - // Rückmeldung. - LabeledContent("settings.serverKind", value: kindLabel) - if let connection = session.server?.connection { - LabeledContent("settings.connection", value: connectionLabel(connection)) - } - if let address = session.serverAddress { - LabeledContent("settings.address", value: address) - .font(.flixyCaption) - .textSelection(.enabled) - } - if let accountName = session.accountName { - LabeledContent("settings.account", value: accountName) - } - } - Section { - ForEach(session.libraries) { library in - LibraryRow(library: library) - } - } header: { - Text("settings.libraries") - } footer: { - // Der Suchlauf stand vorher im Dreipunktmenü der Mediathek - // — einen Tipp neben „Filter" und „Ansicht wechseln". Er - // beschäftigt den Server minutenlang und gehört nicht in - // eine Reihe mit Dingen, die man im Vorbeigehen antippt. - if session.backend?.capabilities.contains(.libraryScan) == true { - Text("settings.scanExplanation") + NavigationLink { + ServerSettingsView() + } label: { + LabeledContent { + Text(session.serverName) + .foregroundStyle(Color.flixyTextSecondary) + } label: { + Label("settings.server", systemImage: "externaldrive.connected.to.line.below") + } } } } + Section("settings.section.look") { + row("appearance.title", symbol: "paintpalette") { AppearanceView() } + row("homeLayout.title", symbol: "rectangle.stack") { HomeLayoutView() } + } + + Section("settings.section.playing") { + row("settings.playback", symbol: "play.rectangle") { PlaybackSettingsView() } + row("languages.title", symbol: "captions.bubble") { LanguagesView() } + row("sidebar.downloads", symbol: "arrow.down.circle") { DownloadSettingsView() } + } + Section { - Picker("settings.qualityWifi", selection: Bindable(settings).wifiQuality) { - ForEach(PlaybackSettings.Quality.allCases) { quality in - Text(quality.label).tag(quality) - } - } - Picker("settings.qualityCellular", selection: Bindable(settings).cellularQuality) { - ForEach(PlaybackSettings.Quality.allCases) { quality in - Text(quality.label).tag(quality) - } - } - } header: { - Text("settings.quality") - } footer: { - Text("settings.qualityExplanation") + row("diagnostics.title", symbol: "stethoscope") { DiagnosticsView() } } - Section("settings.playback") { - Toggle("settings.autoSkipIntro", isOn: Bindable(settings).autoSkipIntro) - Toggle("settings.autoSkipCredits", isOn: Bindable(settings).autoSkipCredits) - Toggle("settings.autoPlayNext", isOn: Bindable(settings).autoPlayNext) - } - - Section { - NavigationLink { - AppearanceView() - } label: { - Label("appearance.title", systemImage: "paintpalette") - } - - NavigationLink { - LanguagesView() - } label: { - Label("languages.title", systemImage: "captions.bubble") - } - - NavigationLink { - HomeLayoutView() - } label: { - Label("homeLayout.title", systemImage: "rectangle.stack") - } - - NavigationLink { - DiagnosticsView() - } label: { - Label("diagnostics.title", systemImage: "stethoscope") - } - } - - downloadSection - Section("settings.app") { LabeledContent("settings.version", value: Branding.version) LabeledContent("settings.build", value: Branding.buildStamp) @@ -141,174 +80,15 @@ struct SettingsView: View { } } - /// Die Download-Einstellungen. - /// - /// Die Löschfrist steht bewusst mit ausgeschriebenen Fristen da statt mit - /// einem Schieberegler: „Nach 7 Tagen" ist eine Entscheidung, die man - /// bewusst trifft — beim Löschen von Filmen soll niemand versehentlich - /// auf „sofort" rutschen. - @ViewBuilder - private var downloadSection: some View { - @Bindable var downloads = downloads - - Section { - Picker("settings.storageLimit", selection: storageLimitBinding) { - Text("settings.storageUnlimited").tag(Int64(0)) - ForEach([Int64(5), 10, 20, 30, 50, 100], id: \.self) { gigabytes in - Text("\(gigabytes) GB").tag(gigabytes * 1_000_000_000) - } - } - - Picker("settings.deleteWatched", selection: deleteAfterBinding) { - Text("settings.deleteNever").tag(Double(-1)) - Text("settings.deleteImmediately").tag(Double(0)) - Text("settings.deleteAfterDay").tag(Duration.afterOneDay.seconds) - Text("settings.deleteAfterWeek").tag(Duration.afterOneWeek.seconds) - } - - Picker("settings.keepUnwatched", selection: keepUnwatchedBinding) { - Text("settings.keepOff").tag(0) - ForEach([1, 2, 3, 5], id: \.self) { count in - Text("settings.keepCount \(count)").tag(count) - } - } - - Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly) - } header: { - Text("sidebar.downloads") - } footer: { - Text("settings.downloadsExplanation") - } - } - - private var storageLimitBinding: Binding { - Binding( - get: { downloads.storageLimitBytes ?? 0 }, - set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil } - ) - } - - private var deleteAfterBinding: Binding { - Binding( - get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 }, - set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil } - ) - } - - private var keepUnwatchedBinding: Binding { - Binding( - get: { downloads.rules.keepUnwatchedEpisodes ?? 0 }, - set: { downloads.rules.keepUnwatchedEpisodes = $0 > 0 ? $0 : nil } - ) - } - - /// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach - /// Ruckeln ist das die erste Frage. - /// Welche Serverart gerade bedient wird. - private var kindLabel: String { - switch session.kind { - case .plex: "Plex" - case .jellyfin: "Jellyfin" - case nil: "—" - } - } - - 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") - } -} - -extension PlaybackSettings.Quality { - var label: LocalizedStringKey { - switch self { - case .original: "quality.original" - case .high: "quality.high" - case .medium: "quality.medium" - case .low: "quality.low" - case .minimal: "quality.minimal" - } - } -} - -extension MediaLibrary.Kind { - /// Wie diese Art von Mediathek heißt. - /// - /// Übersetzt statt durchgereicht: die Server liefern „movies" und - /// „tvshows", und das großgeschrieben anzuzeigen war eine englische - /// Beschriftung in einer deutschen Oberfläche. - var label: String { - switch self { - case .movie: String(localized: "library.kind.movie") - case .show: String(localized: "library.kind.show") - case .music: String(localized: "library.kind.music") - case .photo: String(localized: "library.kind.photo") - case .liveTV: String(localized: "library.kind.liveTV") - case .other: String(localized: "library.kind.other") - } - } -} - -/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben. -private struct LibraryRow: View { - let library: MediaLibrary - - @Environment(FlixySession.self) private var session - @Environment(\.flixyAccent) private var accent - @State private var phase: Phase = .idle - - private enum Phase { case idle, running, done } - - var body: some View { - LabeledContent { - HStack(spacing: FlixySpacing.regular) { - // Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens - // „Filme" stand sonst als „Filme — Filme" da. - if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame { - Text(library.kind.label) - .foregroundStyle(Color.flixyTextSecondary) - } - - if session.backend?.capabilities.contains(.libraryScan) == true { - Button { - Task { await scan() } - } label: { - symbol - } - .buttonStyle(.borderless) - .disabled(phase == .running) - .accessibilityLabel(Text("library.scan")) - } - } + private func row( + _ title: LocalizedStringKey, + symbol: String, + @ViewBuilder destination: @escaping () -> Destination + ) -> some View { + NavigationLink { + destination() } label: { - Text(library.title) + Label(title, systemImage: symbol) } } - - @ViewBuilder - private var symbol: some View { - switch phase { - case .idle: - Image(systemName: "arrow.triangle.2.circlepath") - case .running: - ProgressView().controlSize(.small) - case .done: - Image(systemName: "checkmark").foregroundStyle(accent) - } - } - - /// Stößt einen Suchlauf auf dem Server an. - /// - /// Der Server meldet nicht, wann er fertig ist. Deshalb steht hier kein - /// Fortschritt, sondern nur ein Haken dafür, dass der Auftrag angekommen - /// ist — ein Balken, der etwas anderes behauptet, wäre erfunden. - private func scan() async { - guard let backend = session.backend, phase != .running else { return } - phase = .running - try? await backend.refreshLibrary(library) - phase = .done - try? await Task.sleep(for: .seconds(4)) - phase = .idle - } } diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift index 78279d3..824fbf3 100644 --- a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift @@ -95,7 +95,9 @@ public final class DownloadCoordinator { /// Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden teilt /// die Bandbreite so auf, dass keine Folge fertig wird — und unterwegs ist /// eine fertige Folge mehr wert als zehn halbe. - public var maximumConcurrentDownloads = 2 + public var maximumConcurrentDownloads: Int { + didSet { defaults.set(maximumConcurrentDownloads, forKey: Key.maximumConcurrent) } + } /// Die Regeln fürs automatische Löschen und Nachladen. public var rules: DownloadRules { @@ -110,6 +112,7 @@ public final class DownloadCoordinator { static let storageLimit = "flixy.downloads.storageLimit" static let deleteAfter = "flixy.downloads.deleteWatchedAfter" static let keepUnwatched = "flixy.downloads.keepUnwatched" + static let maximumConcurrent = "flixy.downloads.maximumConcurrent" } /// Der Container, nicht nur der Kontext. @@ -176,6 +179,9 @@ public final class DownloadCoordinator { self.fileStore = fileStore self.defaults = defaults self.isWiFiOnly = defaults.object(forKey: Key.wifiOnly) as? Bool ?? true + // Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden + // teilt die Bandbreite so auf, dass keine Folge fertig wird. + self.maximumConcurrentDownloads = defaults.object(forKey: Key.maximumConcurrent) as? Int ?? 2 let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0 self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift index be50125..35a49be 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift @@ -57,6 +57,44 @@ public enum PlayerQuality: Hashable, Sendable, Identifiable { Rung(bitrate: 64, height: 160), ] + /// Eine Zahl, die sich aufbewahren lässt. + /// + /// Über mehrere Filme hinweg gilt nur die **Absicht** — „ungefähr so viel + /// Bitrate" —, nicht die genaue Sprosse: welche Sprossen es gibt, hängt + /// von der Quelle ab. Deshalb eine Zahl und kein Aufzählungswert. + public var storageValue: Int { + switch self { + case .automatic: 0 + case .original: -1 + case .audioOnly: -2 + case .limited(let rung): rung.bitrate + } + } + + /// Die Stufe zu einer aufbewahrten Zahl, so nah wie möglich. + /// + /// Gibt es die Sprosse für diese Quelle nicht, wird die nächstgelegene + /// genommen — eine 720p-Datei kennt keine 20-Megabit-Stufe, und dann + /// stillschweigend auf „automatisch" zurückzufallen wäre eine andere + /// Antwort als die, um die gebeten wurde. + public static func restored(from value: Int, forSourceHeight height: Int?) -> PlayerQuality? { + switch value { + case 0: return nil + case -1: return .original + case -2: return .audioOnly + default: break + } + + let rungs = available(forSourceHeight: height).compactMap { quality -> Rung? in + if case .limited(let rung) = quality { return rung } + return nil + } + guard let closest = rungs.min(by: { + abs($0.bitrate - value) < abs($1.bitrate - value) + }) else { return nil } + return .limited(closest) + } + public static var allCases: [PlayerQuality] { [.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly] } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index 39269e7..f86e611 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -291,17 +291,50 @@ public struct JellyfinBackend: MediaBackend { ) } + /// Eine verkleinerte Fassung zum Herunterladen. + /// + /// **Fortlaufendes MP4, nicht HLS.** Zum Abspielen liefert Jellyfin eine + /// Abspielliste mit hunderten Schnipseln; das ist für einen Strom richtig + /// und für eine Datei, die auf dem Gerät liegen soll, unbrauchbar. Der + /// Endpunkt `/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als **eine** + /// Datei heraus. + /// + /// Hier wird die Adresse selbst gebaut statt über `/PlaybackInfo` erfragt — + /// anders als beim Abspielen. Der Grund ist die Signatur: diese Stelle darf + /// keine Anfrage stellen, weil die Oberfläche die Adresse beim Zeichnen + /// braucht. Das ist vertretbar, weil hier nichts zu entscheiden ist: die + /// Stufe steht fest, der Server soll sie liefern. + /// + /// Die Größe bleibt eine Schätzung. Eine Umrechnung, die erst beim Abruf + /// beginnt, kennt ihre eigene Länge nicht — der Fortschrittsbalken rechnet + /// deshalb gegen die geschätzten Bytes, nicht gegen eine Zusage des Servers. public nonisolated func compressedDownloadURL( itemID: String, versionIndex: Int, maximumBitrate: Int, resolution: String? ) -> URL? { - // Jellyfin liefert die Adresse für eine umgerechnete Fassung erst nach - // einer Anfrage an `/PlaybackInfo` — das geht hier nicht, weil diese - // Methode keine Anfrage stellen darf. `nil` heißt: nur Original - // anbieten, statt eine Adresse zu erfinden, die nicht antwortet. - nil + var query = [ + URLQueryItem(name: "videoCodec", value: "h264"), + URLQueryItem(name: "audioCodec", value: "aac"), + // Jellyfin rechnet in Bit, die App in Kilobit. + URLQueryItem(name: "videoBitRate", value: String(maximumBitrate * 1_000)), + URLQueryItem(name: "audioBitRate", value: "128000"), + // Zwei Kanäle: ein Handy hat keine Rückkanäle, und 5.1 auf zwei + // Lautsprecher heruntergemischt klingt schlechter als von + // vornherein Stereo. + URLQueryItem(name: "TranscodingMaxAudioChannels", value: "2"), + URLQueryItem(name: "TranscodingContainer", value: "mp4"), + ] + if let height = resolution?.split(separator: "x").last.flatMap({ Int($0) }) { + query.append(URLQueryItem(name: "maxHeight", value: String(height))) + } + + return client.url( + path: "/Videos/\(itemID)/stream.mp4", + query: query, + includingToken: account.token + ) } // MARK: - Zustand melden diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/QualityMemoryTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/QualityMemoryTests.swift new file mode 100644 index 0000000..576f2cd --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/QualityMemoryTests.swift @@ -0,0 +1,48 @@ +import Testing +@testable import FlixyPlayback + +/// Die gemerkte Qualitätsstufe über mehrere Filme hinweg. +/// +/// Aufbewahrt wird eine Zahl, keine Sprosse: welche Sprossen es gibt, hängt +/// von der Quelle ab. Eine 720p-Datei kennt keine Zwanzig-Megabit-Stufe — und +/// dann stillschweigend auf „automatisch" zurückzufallen wäre eine andere +/// Antwort als die, um die gebeten wurde. +@Suite("Gemerkte Qualität") +struct QualityMemoryTests { + + @Test("Null heißt: nichts gemerkt") + func returnsNilForAutomatic() { + #expect(PlayerQuality.automatic.storageValue == 0) + #expect(PlayerQuality.restored(from: 0, forSourceHeight: 1080) == nil) + } + + @Test("Original und Nur-Ton kommen unverändert zurück") + func restoresSpecialCases() { + #expect(PlayerQuality.restored(from: PlayerQuality.original.storageValue, + forSourceHeight: 1080) == .original) + #expect(PlayerQuality.restored(from: PlayerQuality.audioOnly.storageValue, + forSourceHeight: 1080) == .audioOnly) + } + + @Test("Eine vorhandene Sprosse kommt genau zurück") + func restoresExactRung() { + let chosen = PlayerQuality.limited(.init(bitrate: 3_000, height: 720)) + + let restored = PlayerQuality.restored(from: chosen.storageValue, forSourceHeight: 1080) + + #expect(restored == chosen) + } + + @Test("Gibt es die Sprosse nicht, kommt die nächstgelegene") + func fallsBackToClosestRung() { + // Zwanzig Megabit gibt es nur für 1080p. Bei einer 480p-Quelle ist die + // beste erreichbare Stufe eine andere — aber nicht „gar keine". + let restored = PlayerQuality.restored(from: 20_000, forSourceHeight: 480) + + guard case .limited(let rung) = restored else { + Issue.record("keine Stufe zurückbekommen") + return + } + #expect(rung.height <= 480) + } +} diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift b/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift index 98ed857..aad13bd 100644 --- a/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift @@ -141,6 +141,27 @@ struct PlaybackTests { #expect(fields["IsPaused"] as? Bool == false) } + @Test("Verkleinert herunterladen ergibt eine einzelne Datei") + func buildsCompressedDownloadURL() throws { + let url = try #require(backend(RecordingTransport()).compressedDownloadURL( + itemID: "film-1", + versionIndex: 0, + maximumBitrate: 4_000, + resolution: "1280x720" + )) + + // `.mp4`, nicht `.m3u8`: eine Abspielliste mit hunderten Schnipseln + // lässt sich nicht als Datei ablegen. + #expect(url.path == "/Videos/film-1/stream.mp4") + + let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + // In Bit, nicht in Kilobit — eine Null zu wenig, und aus vier Megabit + // werden vier Kilobit. + #expect(query.contains(URLQueryItem(name: "videoBitRate", value: "4000000"))) + #expect(query.contains(URLQueryItem(name: "maxHeight", value: "720"))) + #expect(query.contains(URLQueryItem(name: "ApiKey", value: "tok"))) + } + @Test("Der erste Bericht legt die Sitzung an") func reportsStartSeparately() async throws { let transport = RecordingTransport()