Phase 5: Nachladen, Startseiten-Layout

Nachladen
- DownloadRefiller wählt aus, WELCHE Folgen fehlen; wie viele, rechnet
  die Regel-Engine. Getrennt, weil das Rechnen ohne Server auskommt und
  damit prüfbar ist.
- In Ausstrahlungsreihenfolge, Specials in Staffel 0 bleiben außen vor.
- Läuft nach dem Ansehen und beim Start, nicht auf einem Zeitgeber: eine
  App, die im Hintergrund unaufgefordert Gigabytes lädt, wäre eine
  unangenehme Überraschung auf der Mobilfunkrechnung.

Startseiten-Layout
- Reihen ein-/ausblenden und per Bearbeiten-Modus umsortieren.
- Die Anordnungslogik liegt als HubArrangement in PlexKit statt im
  App-Target — dort ist sie ohne Simulator testbar, und dort sitzen die
  unangenehmen Fälle: neue Reihen vom Server landen am Ende statt zu
  verschwinden, verschwundene aus der gespeicherten Liste stören nicht,
  ein doppelter Eintrag stürzt nicht ab. 11 Tests.

126 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 17:43:20 +02:00
co-authored by Claude Opus 5
parent af23372997
commit b7fc18823a
10 changed files with 455 additions and 2 deletions
+8
View File
@@ -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)
+48
View File
@@ -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"
+77
View File
@@ -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
}
}
+66
View File
@@ -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
}
+3 -1
View File
@@ -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")
+6 -1
View File
@@ -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
+70
View File
@@ -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)
}
}
+6
View File
@@ -62,6 +62,12 @@ struct SettingsView: View {
} label: {
Label("appearance.title", systemImage: "paintpalette")
}
NavigationLink {
HomeLayoutView()
} label: {
Label("homeLayout.title", systemImage: "rectangle.stack")
}
}
downloadSection