diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 76adeb7..0780ed6 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2073,6 +2073,40 @@ } } }, + "home.heroLibraries": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mediatheken für den Aufmacher" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Libraries for the hero" + } + } + } + }, + "home.heroLibrariesExplanation": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ohne Auswahl kommt alles infrage. Angefangenes bleibt immer dabei: aus welcher Mediathek eine begonnene Folge stammt, sagt der Server nicht." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "With nothing selected, everything is eligible. What you have started always stays: the server does not say which library a part-watched episode came from." + } + } + } + }, "home.heroSource": { "extractionState": "stale", "localizations": { diff --git a/Flixy/Session/HomeFeed.swift b/Flixy/Session/HomeFeed.swift index 4dedb6e..ba8388f 100644 --- a/Flixy/Session/HomeFeed.swift +++ b/Flixy/Session/HomeFeed.swift @@ -107,7 +107,11 @@ final class HomeFeed { /// Folge gäbe es sonst nichts zu blättern. Wer den Aufmacher lieber als /// Schaufenster versteht, stellt auf Neuzugänge um. func heroItems(for layout: HomeLayoutSettings) -> [MediaItem] { - let hubs = rows(for: layout) + // **Nur hier, nicht an den Reihen selbst.** Eine abgewählte Mediathek + // verschwindet nicht von der Startseite — sie soll nur nicht mehr + // oben gross stehen. Wer sie ganz loswerden will, blendet die Reihe + // aus; das ist eine andere Frage und hat ihren eigenen Schalter. + let hubs = HeroSelection.rows(rows(for: layout), limitedTo: layout.heroLibraries) let candidates: [MediaItem] switch layout.heroSource { case .continueFirst: diff --git a/Flixy/Session/HomeLayoutSettings.swift b/Flixy/Session/HomeLayoutSettings.swift index a7c0503..198225a 100644 --- a/Flixy/Session/HomeLayoutSettings.swift +++ b/Flixy/Session/HomeLayoutSettings.swift @@ -17,6 +17,7 @@ final class HomeLayoutSettings { static let order = "flixy.home.hubOrder" static let heroCount = "flixy.home.heroCount" static let heroSource = "flixy.home.heroSource" + static let heroLibraries = "flixy.home.heroLibraries" static let combinesContinueRows = "flixy.home.combinesContinueRows" static let hidesWatchedInLatest = "flixy.home.hidesWatchedInLatest" static let cardScale = "flixy.home.cardScale" @@ -55,6 +56,18 @@ final class HomeLayoutSettings { var heroCount: Int { didSet { defaults.set(heroCount, forKey: Key.heroCount) } } var heroSource: HeroSource { didSet { defaults.set(heroSource.rawValue, forKey: Key.heroSource) } } + + /// Aus welchen Mediatheken der Aufmacher sich speist. + /// + /// **Leer heisst alle** — siehe `HeroSelection.rows(_:limitedTo:)`. Eine + /// Auswahl, bei der nichts angekreuzt ist, darf nicht in einen leeren + /// Aufmacher führen. + /// + /// Als `Set` und nicht als eine einzelne Kennung: wer Filme und Serien + /// will, aber keine Musikvideos, hat zwei Antworten und nicht eine. + var heroLibraries: Set { + didSet { defaults.set(Array(heroLibraries), forKey: Key.heroLibraries) } + } /// Ob „Weiterschauen" und „Als Nächstes" eine Reihe sind. /// /// Getrennt ist genauer, zusammen ist kürzer. Beides ist vertretbar, und @@ -88,6 +101,7 @@ final class HomeLayoutSettings { self.heroCount = defaults.object(forKey: Key.heroCount) as? Int ?? 5 self.heroSource = defaults.string(forKey: Key.heroSource) .flatMap(HeroSource.init(rawValue:)) ?? .continueFirst + self.heroLibraries = Set(defaults.stringArray(forKey: Key.heroLibraries) ?? []) self.combinesContinueRows = defaults.object(forKey: Key.combinesContinueRows) as? Bool ?? false self.hidesWatchedInLatest = defaults.object(forKey: Key.hidesWatchedInLatest) as? Bool ?? false // Die Voreinstellungen sind genau das bisherige feste Verhalten: wer diff --git a/Flixy/Views/Settings/HomeLayoutView.swift b/Flixy/Views/Settings/HomeLayoutView.swift index 302c82f..74508ee 100644 --- a/Flixy/Views/Settings/HomeLayoutView.swift +++ b/Flixy/Views/Settings/HomeLayoutView.swift @@ -63,6 +63,44 @@ struct HomeLayoutView: View { } .listRowBackground(Color.flixySurface) + // **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte + // nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag, + // den man versehentlich trifft. Hier sind Haken: keiner gesetzt + // ist der Normalfall und bedeutet, dass alles infrage kommt. + if !session.libraries.isEmpty { + Section { + ForEach(session.libraries) { library in + Button { + toggleHeroLibrary(library.id) + } label: { + LabeledContent { + if layout.heroLibraries.contains(library.id) { + Image(systemName: "checkmark") + .font(.body.weight(.semibold)) + .foregroundStyle(accent) + } + } label: { + Text(library.title) + .foregroundStyle(Color.flixyTextPrimary) + } + // Damit der ganze Streifen trifft und nicht nur + // die Schrift — eine Zeile, bei der nur ein Wort + // reagiert, liest sich als kaputt. + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityAddTraits( + layout.heroLibraries.contains(library.id) ? .isSelected : [] + ) + } + } header: { + Text("home.heroLibraries") + } footer: { + Text("home.heroLibrariesExplanation") + } + .listRowBackground(Color.flixySurface) + } + Section { Toggle("home.combineContinue", isOn: $layout.combinesContinueRows) Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest) @@ -113,6 +151,15 @@ struct HomeLayoutView: View { .scrollContentBackground(.hidden) } + /// Kreuzt eine Mediathek für den Aufmacher an oder ab. + private func toggleHeroLibrary(_ identifier: String) { + if layout.heroLibraries.contains(identifier) { + layout.heroLibraries.remove(identifier) + } else { + layout.heroLibraries.insert(identifier) + } + } + private func load() async { defer { isLoading = false } guard let backend = session.backend else { return } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index 52ded2f..139c98a 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -145,6 +145,7 @@ public func isInLocalNetwork() async -> Bool { localized: "row.latestIn \(library.title)", bundle: .module ), + libraryID: library.id, items: items ?? [] )) } diff --git a/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift b/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift index 762b8ea..195eda9 100644 --- a/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift +++ b/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift @@ -21,6 +21,28 @@ public enum HeroSelection { /// Wie lange dieselbe Auswahl gilt. public static let windowHours: Double = 6 + /// Welche Reihen den Aufmacher überhaupt speisen dürfen. + /// + /// **Leer heisst alle.** Eine Einstellung, bei der nichts angekreuzt ist, + /// soll nicht in einen leeren Aufmacher führen — „ich habe nichts + /// gewählt" heisst „mir ist es gleich", nicht „nimm nichts". + /// + /// **Reihen ohne Mediathek bleiben immer drin.** Weiterschauen, Als + /// Nächstes und Favoriten gehen quer durch alle; welcher Mediathek eine + /// angefangene Folge entstammt, sagt der Server nicht. Sie deshalb + /// wegzuwerfen hiesse, dem Nutzer das Angefangene zu nehmen, weil er eine + /// Mediathek abgewählt hat, die damit nichts zu tun haben muss. + public static func rows( + _ rows: [MediaRowGroup], + limitedTo libraries: Set + ) -> [MediaRowGroup] { + guard !libraries.isEmpty else { return rows } + return rows.filter { row in + guard let libraryID = row.libraryID else { return true } + return libraries.contains(libraryID) + } + } + /// Ordnet die Titel, ohne die Reihenfolge fest vorzugeben. /// /// **Ungleiche Lose.** Ohne Hintergrundbild ist der Aufmacher eine graue diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift b/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift index 2834b1b..8702b0a 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift @@ -59,12 +59,30 @@ public struct MediaRowGroup: Sendable, Equatable, Identifiable { public let title: String /// Der Pfad, unter dem es „alles davon" gäbe. public let moreKey: String? + /// Aus welcher Mediathek diese Reihe stammt. + /// + /// **`nil` heisst: lässt sich nicht zuordnen**, nicht „keine". „Neu in + /// Filme" gehört zu einer Mediathek; „Weiterschauen" und „Favoriten" + /// gehen quer durch alle, und der Server sagt an keiner Stelle, aus + /// welcher ein einzelner angefangener Titel kommt. + /// + /// Gebraucht wird es für die Auswahl des Aufmachers: wer „Musikvideos" + /// dort nicht sehen will, muss die Reihe benennen können, die sie + /// mitbringt. + public let libraryID: String? public let items: [MediaItem] - public init(id: String, title: String, moreKey: String? = nil, items: [MediaItem] = []) { + public init( + id: String, + title: String, + moreKey: String? = nil, + libraryID: String? = nil, + items: [MediaItem] = [] + ) { self.id = id self.title = title self.moreKey = moreKey + self.libraryID = libraryID self.items = items } diff --git a/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift b/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift index 5ccd7bd..d63da88 100644 --- a/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift +++ b/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift @@ -109,3 +109,61 @@ struct HeroSelectionTests { } } } + +@Suite("Mediatheken für den Aufmacher") +struct HeroLibraryFilterTests { + + private func reihe(_ id: String, library: String? = nil) -> MediaRowGroup { + MediaRowGroup( + id: id, + title: id, + libraryID: library, + items: [MediaItem(id: "\(id).1", kind: .movie, title: "Titel")] + ) + } + + private var alle: [MediaRowGroup] { + [reihe("resume"), reihe("latest.filme", library: "filme"), + reihe("latest.serien", library: "serien"), reihe("favourites")] + } + + /// **Der wichtigste Fall.** Wer nichts ankreuzt, meint „mir ist es + /// gleich" — nicht „nimm nichts". Ein leerer Aufmacher wäre hier der + /// Fehler, den niemand mit der Einstellung in Verbindung brächte. + @Test("Ohne Auswahl bleibt alles") + func emptySelectionKeepsEverything() { + #expect(HeroSelection.rows(alle, limitedTo: []).map(\.id) == alle.map(\.id)) + } + + @Test("Eine gewählte Mediathek lässt nur ihre Reihe durch") + func selectionKeepsOnlyThatLibrary() { + let übrig = HeroSelection.rows(alle, limitedTo: ["filme"]).map(\.id) + #expect(übrig.contains("latest.filme")) + #expect(!übrig.contains("latest.serien")) + } + + /// Weiterschauen und Favoriten tragen keine Mediathek — und dürfen durch + /// eine Auswahl nicht verschwinden. Sonst nähme ein abgewähltes + /// „Musikvideos" dem Nutzer das Angefangene. + @Test("Reihen ohne Mediathek bleiben immer") + func rowsWithoutLibraryAlwaysStay() { + let übrig = HeroSelection.rows(alle, limitedTo: ["filme"]).map(\.id) + #expect(übrig.contains("resume")) + #expect(übrig.contains("favourites")) + } + + /// Eine Mediathek, die es nicht mehr gibt — umbenannt, gelöscht, Server + /// gewechselt. Die gespeicherte Kennung bleibt dann stehen und darf nicht + /// dazu führen, dass gar nichts mehr durchkommt. + @Test("Eine unbekannte Kennung lässt die zuordnungslosen Reihen übrig") + func unknownSelectionLeavesTheUnattributable() { + let übrig = HeroSelection.rows(alle, limitedTo: ["gibtsnicht"]).map(\.id) + #expect(übrig == ["resume", "favourites"]) + } + + @Test("Mehrere gewählte Mediatheken kommen alle durch") + func multipleSelection() { + let übrig = HeroSelection.rows(alle, limitedTo: ["filme", "serien"]).map(\.id) + #expect(übrig == alle.map(\.id)) + } +}