diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 448ebb3..66bdd12 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -22,6 +22,7 @@ struct FlixyApp: App { /// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal /// läuft statt bei jedem Öffnen des Players neu zu starten. @State private var network = NetworkConditions() + @State private var homeLayout = HomeLayoutSettings() @State private var downloads: DownloadCoordinator private let modelContainer: ModelContainer @@ -57,6 +58,7 @@ struct FlixyApp: App { .environment(playbackSettings) .environment(network) .environment(downloads) + .environment(homeLayout) .environment(theme) .flixyAccent(theme) // Der eigentliche Schalter für „nur dunkel" steht in der @@ -67,6 +69,12 @@ struct FlixyApp: App { .task { appDelegate.downloads = downloads await downloads.restore() + // Beim Start einmal prüfen: während die App weg war, + // können Folgen gesehen oder gelöscht worden sein. + await DownloadRefiller( + server: session.server, + coordinator: downloads + ).refill() } } .modelContainer(modelContainer) diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index b0c4eb9..c135abf 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2962,6 +2962,54 @@ } } } + }, + "homeLayout.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Startseite" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Home screen" + } + } + } + }, + "homeLayout.explanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schalte Reihen aus, die du nicht brauchst, und sortiere sie über „Bearbeiten“ um. Neue Reihen vom Server erscheinen am Ende." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn off rows you don't need and reorder them via Edit. New rows from the server appear at the end." + } + } + } + }, + "homeLayout.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server liefert derzeit keine Reihen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The server isn't providing any rows right now." + } + } + } } }, "version": "1.1" diff --git a/Flixy/Session/DownloadRefiller.swift b/Flixy/Session/DownloadRefiller.swift new file mode 100644 index 0000000..3881380 --- /dev/null +++ b/Flixy/Session/DownloadRefiller.swift @@ -0,0 +1,77 @@ +import Foundation +import FlixyDownloads +import PlexKit + +/// Füllt den Vorrat an ungesehenen Folgen wieder auf. +/// +/// Die Regel-Engine rechnet aus, *wie viele* Folgen je Serie fehlen; hier wird +/// entschieden, *welche*. Die Trennung ist Absicht: das Rechnen braucht keinen +/// Server und ist deshalb prüfbar, das Auswählen braucht ihn zwangsläufig. +@MainActor +struct DownloadRefiller { + let server: PlexServer? + let coordinator: DownloadCoordinator + + /// Lädt nach, was den Regeln nach fehlt. + /// + /// Läuft nach dem Ansehen einer Folge und beim Start — nicht auf einem + /// Zeitgeber. Eine App, die im Hintergrund unaufgefordert Gigabytes lädt, + /// wäre eine unangenehme Überraschung auf der Mobilfunkrechnung. + @discardableResult + func refill() async -> Int { + guard let server, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 } + + let needed = coordinator.rules.seriesNeedingRefill(in: coordinator.items) + guard !needed.isEmpty else { return 0 } + + var added = 0 + let service = DownloadService(server: server, coordinator: coordinator) + + for (seriesKey, count) in needed { + let candidates = await nextUnwatchedEpisodes( + inSeries: seriesKey, + count: count, + server: server + ) + + for episode in candidates { + // Die Kinderliste bringt keine Dateiangaben mit — ohne sie + // ließe sich weder das Speicherlimit prüfen noch ein + // Fortschritt anzeigen. + let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode + guard let plan = service.plan(for: detailed) else { continue } + if (try? coordinator.enqueue(plan)) != nil { added += 1 } + } + } + return added + } + + /// Die nächsten ungesehenen Folgen einer Serie, die noch nicht offline sind. + /// + /// In Ausstrahlungsreihenfolge, nicht nach Beliebtheit oder Zufall: wer + /// drei Folgen vorhalten lässt, will die drei, die als Nächstes dran sind. + private func nextUnwatchedEpisodes( + inSeries seriesKey: String, + count: Int, + server: PlexServer + ) async -> [PlexItem] { + guard let seasons = try? await server.children(of: seriesKey) else { return [] } + + let offlineKeys = Set(coordinator.items.map(\.ratingKey)) + var result: [PlexItem] = [] + + for season in seasons.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) { + // Specials führt Plex als Staffel 0. Sie gehören nicht in den + // Vorrat für „was kommt als Nächstes". + guard (season.index ?? 0) > 0 else { continue } + guard let episodes = try? await server.children(of: season.ratingKey) else { continue } + + for episode in episodes.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) { + guard !episode.isWatched, !offlineKeys.contains(episode.ratingKey) else { continue } + result.append(episode) + if result.count >= count { return result } + } + } + return result + } +} diff --git a/Flixy/Session/HomeLayoutSettings.swift b/Flixy/Session/HomeLayoutSettings.swift new file mode 100644 index 0000000..97e5267 --- /dev/null +++ b/Flixy/Session/HomeLayoutSettings.swift @@ -0,0 +1,66 @@ +import Foundation +import Observation +import PlexKit + +/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge. +/// +/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht +/// `HubArrangement` in PlexKit — dort ist es reine Rechnung und ohne Simulator +/// prüfbar, und genau dort liegen die kniffligen Fälle: neue Reihen vom +/// Server, verschwundene aus der gespeicherten Liste. +@MainActor +@Observable +final class HomeLayoutSettings { + private enum Key { + static let hidden = "flixy.home.hiddenHubs" + static let order = "flixy.home.hubOrder" + } + + private let defaults: UserDefaults + private(set) var arrangement: HubArrangement + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.arrangement = HubArrangement( + hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []), + order: defaults.stringArray(forKey: Key.order) ?? [] + ) + } + + func isHidden(_ identifier: String) -> Bool { + arrangement.hidden.contains(identifier) + } + + func setHidden(_ hidden: Bool, for identifier: String) { + if hidden { + arrangement.hidden.insert(identifier) + } else { + arrangement.hidden.remove(identifier) + } + defaults.set(Array(arrangement.hidden), forKey: Key.hidden) + } + + func setOrder(_ identifiers: [String]) { + arrangement.order = identifiers + defaults.set(identifiers, forKey: Key.order) + } + + func apply(to hubs: [PlexHub]) -> [PlexHub] { + arrangement.apply(to: hubs) + } + + /// Die Zeilen für den Einstellungsbildschirm. + func rows(from hubs: [PlexHub]) -> [HomeRow] { + arrangement.arrangedIdentifiers(from: hubs).compactMap { identifier in + guard let hub = hubs.first(where: { $0.hubIdentifier == identifier }) else { return nil } + return HomeRow(id: identifier, title: hub.title, isHidden: isHidden(identifier)) + } + } +} + +/// Eine Reihe im Einstellungsbildschirm. +struct HomeRow: Identifiable, Hashable { + let id: String + let title: String + var isHidden: Bool +} diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index ff30e0c..0be4314 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -5,6 +5,7 @@ import PlexKit /// Die Startseite: Aufmacher oben, darunter die Reihen vom Server. struct HomeView: View { @Environment(FlixySession.self) private var session + @Environment(HomeLayoutSettings.self) private var layout @State private var state: LoadState<[PlexHub]> = .loading var body: some View { @@ -22,7 +23,8 @@ struct HomeView: View { Button("action.retry") { Task { await load() } } } - case .loaded(let hubs): + case .loaded(let allHubs): + let hubs = layout.apply(to: allHubs) if hubs.isEmpty { ContentUnavailableView { Label("home.empty.title", systemImage: "film.stack") diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 494b04b..3d5dbcd 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -381,8 +381,13 @@ final class PlayerModel { case .reachedEnd: isPlaying = false hasReachedEnd = true - // Grundlage fürs automatische Löschen nach dem Ansehen. + // Grundlage fürs automatische Löschen nach dem Ansehen — + // und der Auslöser fürs Nachladen: der Vorrat ist jetzt + // um eine Folge kleiner. downloads.markWatched(ratingKey: request.ratingKey) + Task { [server, downloads] in + await DownloadRefiller(server: server, coordinator: downloads).refill() + } case .tracksResolved(let audio, let subtitle): audioTracks = audio subtitleTracks = subtitle diff --git a/Flixy/Views/Settings/HomeLayoutView.swift b/Flixy/Views/Settings/HomeLayoutView.swift new file mode 100644 index 0000000..b76497d --- /dev/null +++ b/Flixy/Views/Settings/HomeLayoutView.swift @@ -0,0 +1,70 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Reihen der Startseite ein- und ausblenden und umsortieren. +struct HomeLayoutView: View { + @Environment(FlixySession.self) private var session + @Environment(HomeLayoutSettings.self) private var layout + + @State private var rows: [HomeRow] = [] + @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 + } + } + .background(Color.flixyCanvas) + .navigationTitle(Text("homeLayout.title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { EditButton() } + .task { await load() } + } + + private var list: some View { + List { + Section { + ForEach($rows) { $row in + Toggle(isOn: Binding( + get: { !row.isHidden }, + set: { + row.isHidden = !$0 + layout.setHidden(!$0, for: row.id) + } + )) { + Text(row.title) + .foregroundStyle(Color.flixyTextPrimary) + } + .listRowBackground(Color.flixySurface) + } + .onMove { indices, destination in + rows.move(fromOffsets: indices, toOffset: destination) + layout.setOrder(rows.map(\.id)) + } + } footer: { + Text("homeLayout.explanation") + } + } + .scrollContentBackground(.hidden) + } + + private func load() async { + defer { isLoading = false } + guard let server = session.server else { return } + // Die Reihen kommen vom Server — es gibt keine feste Liste, die sich + // vorweg anzeigen ließe. Was hier steht, hängt davon ab, welche + // Mediatheken und Sammlungen angelegt sind. + let hubs = (try? await server.homeHubs()) ?? [] + rows = layout.rows(from: hubs) + } +} diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index efa0488..bb0f8e6 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -62,6 +62,12 @@ struct SettingsView: View { } label: { Label("appearance.title", systemImage: "paintpalette") } + + NavigationLink { + HomeLayoutView() + } label: { + Label("homeLayout.title", systemImage: "rectangle.stack") + } } downloadSection diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/HubArrangement.swift b/Packages/FlixyKit/Sources/PlexKit/Models/HubArrangement.swift new file mode 100644 index 0000000..4363b49 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Models/HubArrangement.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Wie die Reihen der Startseite angeordnet werden. +/// +/// Reine Rechnung ohne Speicher und ohne Oberfläche — die Stelle, an der die +/// zwei unangenehmen Fälle entschieden werden: was mit Reihen geschieht, die +/// der Server neu liefert, und was mit gespeicherten, die es nicht mehr gibt. +public struct HubArrangement: Sendable, Equatable { + /// Ausdrücklich ausgeblendete Reihen. + public var hidden: Set + /// Die gewünschte Reihenfolge. Reihen, die hier fehlen, landen am Ende. + public var order: [String] + + public init(hidden: Set = [], order: [String] = []) { + self.hidden = hidden + self.order = order + } + + /// Wendet Auswahl und Reihenfolge an. + /// + /// Unbekannte Reihen landen am Ende statt zu verschwinden. Sie zu + /// verwerfen wäre die einfachere Regel — und der Grund dafür, dass ein + /// neu angelegtes Regal auf dem Server in der App nie aufgetaucht wäre. + public func apply(to hubs: [PlexHub]) -> [PlexHub] { + let visible = hubs.filter { !hidden.contains($0.hubIdentifier) } + guard !order.isEmpty else { return visible } + + let positions = Dictionary( + order.enumerated().map { ($1, $0) }, + // Ein doppelter Eintrag in der gespeicherten Reihenfolge — etwa + // nach einem fehlgeschlagenen Schreibvorgang — soll nicht + // abstürzen. Die erste Position gewinnt. + uniquingKeysWith: { first, _ in first } + ) + + return visible.enumerated().sorted { left, right in + let leftPosition = positions[left.element.hubIdentifier] ?? Int.max + let rightPosition = positions[right.element.hubIdentifier] ?? Int.max + // Bei gleicher Position — beide unbekannt — bleibt die Reihenfolge + // des Servers erhalten. Ohne diese Rückfalllinie wäre die + // Anordnung neuer Reihen zufällig. + if leftPosition == rightPosition { return left.offset < right.offset } + return leftPosition < rightPosition + }.map(\.element) + } + + /// Die Liste für den Einstellungsbildschirm: gespeicherte Reihenfolge + /// zuerst, danach alles, was der Server sonst noch liefert. + /// + /// Gespeicherte Reihen, die es nicht mehr gibt, fallen dabei heraus — sie + /// anzuzeigen hieße, eine Reihe anzubieten, die nie erscheinen kann. + public func arrangedIdentifiers(from hubs: [PlexHub]) -> [String] { + var result: [String] = [] + var seen = Set() + + for identifier in order + where !seen.contains(identifier) && hubs.contains(where: { $0.hubIdentifier == identifier }) { + result.append(identifier) + seen.insert(identifier) + } + for hub in hubs where !seen.contains(hub.hubIdentifier) { + result.append(hub.hubIdentifier) + seen.insert(hub.hubIdentifier) + } + return result + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/HubArrangementTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/HubArrangementTests.swift new file mode 100644 index 0000000..b3b58cd --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/HubArrangementTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing +@testable import PlexKit + +private func hub(_ identifier: String) -> PlexHub { + PlexHub(hubIdentifier: identifier, title: identifier.capitalized) +} + +@Suite("Anordnung der Startseiten-Reihen") +struct HubArrangementTests { + private let serverHubs = [hub("continue"), hub("recent"), hub("watchlist"), hub("collections")] + + @Test("Ohne Einstellungen bleibt die Reihenfolge des Servers") + func defaultKeepsServerOrder() { + let result = HubArrangement().apply(to: serverHubs) + #expect(result.map(\.hubIdentifier) == ["continue", "recent", "watchlist", "collections"]) + } + + @Test("Ausgeblendete Reihen verschwinden") + func hiddenRowsAreRemoved() { + let arrangement = HubArrangement(hidden: ["watchlist", "collections"]) + let result = arrangement.apply(to: serverHubs) + #expect(result.map(\.hubIdentifier) == ["continue", "recent"]) + } + + @Test("Die gespeicherte Reihenfolge wird angewendet") + func storedOrderIsApplied() { + let arrangement = HubArrangement(order: ["watchlist", "continue", "recent", "collections"]) + let result = arrangement.apply(to: serverHubs) + #expect(result.map(\.hubIdentifier) == ["watchlist", "continue", "recent", "collections"]) + } + + @Test("Neue Reihen vom Server landen am Ende statt zu verschwinden") + func unknownRowsAppearAtTheEnd() { + // Der Fall, der die einfachere Regel — nur Bekanntes anzeigen — zur + // Falle macht: ein neu angelegtes Regal auf dem Server wäre in der App + // nie aufgetaucht, ohne dass jemand den Grund gesehen hätte. + let arrangement = HubArrangement(order: ["recent", "continue"]) + let result = arrangement.apply(to: serverHubs) + #expect(result.map(\.hubIdentifier) == ["recent", "continue", "watchlist", "collections"]) + } + + @Test("Mehrere neue Reihen behalten die Reihenfolge des Servers") + func multipleUnknownRowsKeepServerOrder() { + // Ohne stabile Rückfalllinie wäre ihre Anordnung zufällig — und + // änderte sich bei jedem Start. + let arrangement = HubArrangement(order: ["collections"]) + let result = arrangement.apply(to: serverHubs) + #expect(result.map(\.hubIdentifier) == ["collections", "continue", "recent", "watchlist"]) + } + + @Test("Gespeicherte Reihen, die es nicht mehr gibt, stören nicht") + func staleOrderEntriesAreIgnored() { + // Entsteht beim Löschen einer Mediathek auf dem Server. + let arrangement = HubArrangement(order: ["geloescht", "recent", "continue"]) + let result = arrangement.apply(to: serverHubs) + #expect(result.map(\.hubIdentifier) == ["recent", "continue", "watchlist", "collections"]) + } + + @Test("Ein doppelter Eintrag in der Reihenfolge stürzt nicht ab") + func duplicateOrderEntriesAreTolerated() { + // Kann nach einem abgebrochenen Schreibvorgang entstehen. Ein + // Dictionary aus doppelten Schlüsseln wäre ein harter Absturz. + let arrangement = HubArrangement(order: ["recent", "recent", "continue"]) + let result = arrangement.apply(to: serverHubs) + #expect(result.first?.hubIdentifier == "recent") + #expect(result.count == 4) + } + + @Test("Ausblenden und Sortieren greifen zusammen") + func hidingAndOrderingCombine() { + let arrangement = HubArrangement( + hidden: ["recent"], + order: ["collections", "recent", "continue"] + ) + let result = arrangement.apply(to: serverHubs) + #expect(result.map(\.hubIdentifier) == ["collections", "continue", "watchlist"]) + } + + // MARK: - Die Liste für die Einstellungen + + @Test("Der Einstellungsbildschirm zeigt Gespeichertes zuerst") + func settingsListPutsStoredFirst() { + let arrangement = HubArrangement(order: ["watchlist", "continue"]) + let identifiers = arrangement.arrangedIdentifiers(from: serverHubs) + #expect(identifiers == ["watchlist", "continue", "recent", "collections"]) + } + + @Test("Der Einstellungsbildschirm zeigt auch ausgeblendete Reihen") + func settingsListIncludesHidden() { + // Sonst ließe sich eine einmal ausgeblendete Reihe nie wieder + // einschalten — eine Einbahnstraße. + let arrangement = HubArrangement(hidden: ["recent"]) + let identifiers = arrangement.arrangedIdentifiers(from: serverHubs) + #expect(identifiers.contains("recent")) + } + + @Test("Verschwundene Reihen erscheinen nicht in den Einstellungen") + func settingsListDropsStaleEntries() { + let arrangement = HubArrangement(order: ["geloescht", "recent"]) + let identifiers = arrangement.arrangedIdentifiers(from: serverHubs) + #expect(!identifiers.contains("geloescht")) + } +}