Phase 3: Download-Engine und Offline-Wiedergabe

Engine
- Hintergrund-URLSession: läuft weiter, wenn die App zu ist. Der ganze
  Zustand liegt in SwiftData, weil es nach einem Neustart kein anderes
  Gedächtnis gibt.
- Pausieren über cancel(byProducingResumeData:) — nur so lässt sich
  weiterladen statt neu zu beginnen. Bei 40 GB der Unterschied
  zwischen brauchbar und unbrauchbar.
- Zwei Übertragungen gleichzeitig, Folgen in Staffel-/Folgenreihenfolge.
  Alles parallel zu laden hieße: keine Folge wird fertig.
- Speicherlimit und Geräteplatz werden vor dem Annehmen geprüft, ein
  Gigabyte bleibt als Puffer frei.
- Verwaiste Dateien werden beim Start entfernt; sie entstehen, wenn die
  App genau beim Abschluss beendet wird.
- Downloads liegen in Application Support, nicht in Caches (iOS räumt
  den eigenmächtig leer) und sind von der iCloud-Sicherung ausgenommen.

Oberfläche
- Download-Knopf auf Detailseiten mit vier Zuständen in einem Knopf.
- „Alle Folgen laden" für Staffel und Serie; ein abgelehnter Titel
  stoppt den Stapel nicht.
- Downloads-Ansicht mit Belegung, WLAN-Schalter, Pausieren, Fortsetzen,
  Löschen — nach Serie gruppiert.

Offline
- Der Player bevorzugt die lokale Datei, auch ohne erreichbaren Server.
- Beim Herunterladen wird ein Abzug der Serverangaben mitgesichert:
  offline lässt sich nichts nachfragen, und der PlaybackDecider braucht
  Container und Codecs.

Ein Fehler, den die Tests aufgedeckt haben: der Koordinator hielt nur
den ModelContext, nicht den ModelContainer. Ein ModelContext hält seinen
Container nicht am Leben — wird der anderswo freigegeben, stürzt jeder
Zugriff auf einen Eintrag ab, und die Meldung zeigt auf ModelContext.reset
statt auf die Ursache. Jetzt liegt die Zuständigkeit beim Koordinator.

102 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 17:34:57 +02:00
co-authored by Claude Opus 5
parent 55bcfe2fbf
commit 62158303a2
14 changed files with 2175 additions and 6 deletions
+63 -2
View File
@@ -1,13 +1,20 @@
import SwiftData
import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import PlexKit
@main
struct FlixyApp: App {
/// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von
/// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen
/// eigenen Weg — nur den klassischen App-Delegaten.
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch die
/// eingestellte Farbe zurücksetzen.
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
/// Downloads zurücksetzen.
@State private var theme = FlixyTheme()
@State private var session: FlixySession
@State private var playback = PlaybackCoordinator()
@@ -15,6 +22,9 @@ 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 downloads: DownloadCoordinator
private let modelContainer: ModelContainer
init() {
let identity = PlexClientIdentity.current(
@@ -25,6 +35,18 @@ struct FlixyApp: App {
identity: identity,
tokenStore: KeychainTokenStore(service: Branding.keychainService)
))
// Scheitert das Anlegen der Datenbank, ist die App nicht sinnvoll zu
// benutzen — dann lieber hier abbrechen als später bei jedem Download
// stillschweigend nichts tun.
let container = try! ModelContainer(for: DownloadItem.self)
self.modelContainer = container
let fileStore = try! DownloadFileStore()
_downloads = State(initialValue: DownloadCoordinator(
container: container,
fileStore: fileStore
))
}
var body: some Scene {
@@ -34,6 +56,7 @@ struct FlixyApp: App {
.environment(playback)
.environment(playbackSettings)
.environment(network)
.environment(downloads)
.environment(theme)
.flixyAccent(theme)
// Der eigentliche Schalter für „nur dunkel" steht in der
@@ -41,6 +64,44 @@ struct FlixyApp: App {
// Absicherung für SwiftUI-Vorschauen, die sie nicht lesen.
.preferredColorScheme(.dark)
.tint(theme.accentColor)
.task {
appDelegate.downloads = downloads
await downloads.restore()
}
}
.modelContainer(modelContainer)
}
}
/// Der klassische App-Delegat, für das eine, was SwiftUI nicht abdeckt.
@MainActor
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var downloads: DownloadCoordinator?
/// Nicht `@Sendable`: UIKit reicht den Block ohne diese Zusage herein,
/// und er wird ohnehin nur auf dem Hauptakteur aufgerufen. Der Delegat
/// selbst ist an den Hauptakteur gebunden.
private var pendingCompletionHandler: (() -> Void)?
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
// iOS hat die App im Hintergrund gestartet, weil Downloads fertig
// geworden sind. Der Abschlussblock muss aufgerufen werden, sobald
// alles verarbeitet ist — sonst beendet das System die App hart und
// merkt sich das für künftige Aufweckversuche negativ.
guard identifier == DownloadCoordinator.sessionIdentifier else {
completionHandler()
return
}
pendingCompletionHandler = completionHandler
Task { [weak self] in
await self?.downloads?.restore()
self?.pendingCompletionHandler?()
self?.pendingCompletionHandler = nil
}
}
}
+401
View File
@@ -2176,6 +2176,407 @@
}
}
}
},
"downloads.empty.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nichts offline"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing offline"
}
}
}
},
"downloads.empty.description": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Lade Filme und Folgen herunter, um sie ohne Verbindung anzusehen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download movies and episodes to watch them without a connection."
}
}
}
},
"downloads.active": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Läuft gerade"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "In progress"
}
}
}
},
"downloads.movies": {
"comment": "Überschrift für heruntergeladene Filme",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Filme"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Movies"
}
}
}
},
"downloads.used": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Belegt"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Used"
}
}
}
},
"downloads.wifiOnly": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nur über WLAN laden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download over Wi-Fi only"
}
}
}
},
"downloads.pause": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Anhalten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Pause"
}
}
}
},
"downloads.resume": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Fortsetzen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Resume"
}
}
}
},
"downloads.cancel": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Download abbrechen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Cancel download"
}
}
}
},
"downloads.download": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Herunterladen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download"
}
}
}
},
"downloads.available": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Offline verfügbar"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Available offline"
}
}
}
},
"downloads.queued": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wartet"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Queued"
}
}
}
},
"downloads.paused": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Angehalten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Paused"
}
}
}
},
"downloads.failed": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Fehlgeschlagen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Failed"
}
}
}
},
"downloads.removeConfirm": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Download entfernen?"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Remove download?"
}
}
}
},
"downloads.allEpisodes": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Alle Folgen laden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download all episodes"
}
}
}
},
"downloads.season": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Staffel laden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download season"
}
}
}
},
"downloads.batch.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Downloads"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Downloads"
}
}
}
},
"downloads.batch.queued %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Folgen in die Warteschlange gelegt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Queued %lld episodes."
}
}
}
},
"downloads.batch.unavailable %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Folgen haben keine abspielbare Datei auf dem Server."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld episodes have no playable file on the server."
}
}
}
},
"downloads.refused.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Download nicht möglich"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Can't download"
}
}
}
},
"downloads.refused.exists": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dieser Titel steht bereits in der Warteschlange."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "This title is already queued."
}
}
}
},
"downloads.refused.limit %@ %@": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Das Speicherlimit ist erreicht: %@ von %@ belegt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Storage limit reached: %@ of %@ used."
}
}
}
},
"downloads.refused.space %@ %@": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zu wenig Platz auf dem Gerät: %@ nötig, %@ frei."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Not enough space on this device: %@ needed, %@ free."
}
}
}
},
"action.delete": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Löschen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Delete"
}
}
}
}
},
"version": "1.1"
+145
View File
@@ -0,0 +1,145 @@
import Foundation
import FlixyDownloads
import PlexKit
/// Übersetzt Titel vom Server in Download-Aufträge.
///
/// Die Trennung ist Absicht: `DownloadCoordinator` kennt nur Adressen, Bytes
/// und Zustände — nichts von Plex. Dadurch bleibt die Engine ohne Server
/// testbar, und dieser Typ ist die einzige Stelle, die beides kennt.
@MainActor
struct DownloadService {
let server: PlexServer?
let coordinator: DownloadCoordinator
/// Baut den Auftrag für einen einzelnen Titel.
///
/// `nil`, wenn es keine abspielbare Datei gibt — bei einem Titel, den der
/// Server noch nicht analysiert hat, etwa.
func plan(for item: PlexItem, mediaIndex: Int = 0) -> DownloadPlan? {
guard let server else { return nil }
let media = item.media.indices.contains(mediaIndex)
? item.media[mediaIndex]
: item.preferredMedia
guard let media, let part = media.parts.first,
let url = server.downloadURL(part: part)
else { return nil }
return DownloadPlan(
ratingKey: item.ratingKey,
title: item.rowTitle,
subtitle: item.downloadSubtitle,
sourceURL: url,
seriesKey: item.grandparentRatingKey,
seriesTitle: item.grandparentTitle,
seasonKey: item.parentRatingKey,
seasonIndex: item.parentIndex,
episodeIndex: item.index,
durationMilliseconds: item.duration,
expectedBytes: part.size ?? 0,
offlineMedia: offlineMedia(for: item, media: media, part: part)
)
}
/// Lädt einen einzelnen Titel.
@discardableResult
func download(_ item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadItem? {
guard let plan = plan(for: item, mediaIndex: mediaIndex) else { return nil }
return try coordinator.enqueue(plan)
}
/// Lädt alle Folgen einer Staffel oder Serie.
///
/// Holt die Metadaten jeder Folge einzeln nach: die Kinderliste enthält
/// keine Dateiangaben, und ohne Dateigröße ließe sich weder das
/// Speicherlimit prüfen noch ein Fortschrittsbalken zeigen.
func downloadAll(
in container: PlexItem
) async -> (accepted: Int, refused: [(PlexItem, DownloadRefusal)], unavailable: Int) {
guard let server else { return (0, [], 0) }
let episodes: [PlexItem]
do {
episodes = try await allEpisodes(in: container, server: server)
} catch {
return (0, [], 0)
}
var accepted = 0
var refused: [(PlexItem, DownloadRefusal)] = []
var unavailable = 0
for episode in episodes {
// Folgen, die schon offline sind, werden übersprungen statt
// abgelehnt — bei einer Serie, von der die Hälfte geladen ist,
// wäre eine Liste aus zwanzig Fehlern keine Hilfe.
if coordinator.items.contains(where: { $0.ratingKey == episode.ratingKey }) {
continue
}
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
guard let plan = plan(for: detailed) else {
unavailable += 1
continue
}
do {
_ = try coordinator.enqueue(plan)
accepted += 1
} catch {
refused.append((episode, error))
// Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen
// noch einzeln durchzuprobieren — sie scheitern alle gleich.
if case .storageLimitReached = error { break }
if case .insufficientDeviceSpace = error { break }
}
}
return (accepted, refused, unavailable)
}
private func allEpisodes(in container: PlexItem, server: PlexServer) async throws -> [PlexItem] {
switch container.kind {
case .season:
return try await server.children(of: container.ratingKey)
case .show:
var episodes: [PlexItem] = []
for season in try await server.children(of: container.ratingKey) {
episodes.append(contentsOf: try await server.children(of: season.ratingKey))
}
return episodes
default:
return [container]
}
}
/// Der Abzug der Serverangaben, den der Player offline braucht.
private func offlineMedia(for item: PlexItem, media: PlexMedia, part: PlexPart) -> OfflineMedia {
OfflineMedia(
container: part.container ?? media.container,
videoCodec: media.videoCodec,
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec,
videoResolution: media.videoResolution,
bitrate: media.bitrate,
audioLanguages: part.audioStreams.compactMap(\.language),
subtitleLanguages: part.subtitleStreams.compactMap(\.language),
markers: item.markers.map {
OfflineMarker(
type: $0.type,
startMilliseconds: $0.startTimeOffset,
endMilliseconds: $0.endTimeOffset
)
}
)
}
}
extension PlexItem {
/// Die Zeile unter dem Titel in der Downloads-Ansicht.
var downloadSubtitle: String? {
if kind == .episode, let season = parentIndex, let episode = index {
return "\(String(localized: "episode.short \(season) \(episode)")) · \(title)"
}
return year.map(String.init)
}
}
@@ -96,6 +96,8 @@ struct PlayableDetailView: View {
.buttonStyle(.bordered)
.tint(Color.flixyTextSecondary)
}
DownloadButton(item: item, mediaIndex: selectedVersion)
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ struct SeriesDetailView: View {
height: 1200
)
) {
EmptyView()
DownloadAllButton(container: item)
}
}
+165
View File
@@ -0,0 +1,165 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import PlexKit
/// Der Download-Knopf auf einer Detailseite.
///
/// Zeigt vier Zustände in einem Knopf: nicht geladen, lädt gerade, offline
/// verfügbar, gescheitert. Ein getrennter Knopf je Zustand würde die
/// Kopfzeile überfüllen — und der Zustand ist ohnehin immer genau einer.
struct DownloadButton: View {
let item: PlexItem
var mediaIndex: Int = 0
@Environment(FlixySession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
@State private var refusal: DownloadRefusal?
private var existing: DownloadItem? {
downloads.items.first { $0.ratingKey == item.ratingKey }
}
var body: some View {
Button {
act()
} label: {
Label(label, systemImage: symbol)
.labelStyle(.iconOnly)
.font(.flixyCardTitle)
.padding(FlixySpacing.snug)
}
.buttonStyle(.bordered)
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
.accessibilityLabel(Text(label))
.alert(
Text("downloads.refused.title"),
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
) {
Button("action.close") { refusal = nil }
} message: {
if let refusal { Text(refusal.explanation) }
}
}
private var symbol: String {
switch existing?.state {
case .completed: "checkmark.circle.fill"
case .downloading, .queued, .waiting: "stop.circle"
case .paused: "play.circle"
case .failed: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90"
case nil: "arrow.down.circle"
}
}
private var label: LocalizedStringKey {
switch existing?.state {
case .completed: "downloads.available"
case .downloading, .queued, .waiting: "downloads.cancel"
case .paused: "downloads.resume"
case .failed: "action.retry"
case nil: "downloads.download"
}
}
private func act() {
guard let existing else {
enqueue()
return
}
switch existing.state {
case .completed:
// Ein fertiger Download wird hier nicht angetastet. Löschen geht
// über die Downloads-Ansicht — ein Fehlgriff in der Kopfzeile
// soll nicht 40 GB verwerfen.
break
case .downloading, .queued, .waiting:
Task { await downloads.cancel(existing) }
case .paused, .failed:
Task { await downloads.resume(existing) }
}
}
private func enqueue() {
let service = DownloadService(server: session.server, coordinator: downloads)
do {
_ = try service.download(item, mediaIndex: mediaIndex)
} catch {
refusal = error
}
}
}
/// Der Knopf für eine ganze Staffel oder Serie.
struct DownloadAllButton: View {
let container: PlexItem
@Environment(FlixySession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
@State private var isWorking = false
@State private var summary: String?
var body: some View {
Button {
Task { await downloadAll() }
} label: {
if isWorking {
ProgressView().controlSize(.small)
} else {
Label(
container.kind == .show ? "downloads.allEpisodes" : "downloads.season",
systemImage: "arrow.down.circle"
)
}
}
.buttonStyle(.bordered)
.tint(Color.flixyTextSecondary)
.disabled(isWorking)
.alert(
Text("downloads.batch.title"),
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
) {
Button("action.close") { summary = nil }
} message: {
if let summary { Text(summary) }
}
}
private func downloadAll() async {
isWorking = true
defer { isWorking = false }
let service = DownloadService(server: session.server, coordinator: downloads)
let result = await service.downloadAll(in: container)
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
if !result.refused.isEmpty {
// Der erste Grund reicht: bei einem erreichten Speicherlimit ist
// er für alle übrigen derselbe.
lines.append(result.refused[0].1.explanation)
}
if result.unavailable > 0 {
lines.append(String(localized: "downloads.batch.unavailable \(result.unavailable)"))
}
summary = lines.joined(separator: "\n")
}
}
extension DownloadRefusal {
/// Der Grund im Klartext.
var explanation: String {
switch self {
case .alreadyExists:
String(localized: "downloads.refused.exists")
case .storageLimitReached(let limit, let used):
String(localized: "downloads.refused.limit \(used.formatted(.byteCount(style: .file))) \(limit.formatted(.byteCount(style: .file)))")
case .insufficientDeviceSpace(let required, let available):
String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))")
}
}
}
+246
View File
@@ -0,0 +1,246 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
/// Was offline ist und was gerade geladen wird.
struct DownloadsView: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.flixyAccent) private var accent
@State private var itemPendingRemoval: DownloadItem?
var body: some View {
Group {
if downloads.items.isEmpty {
ContentUnavailableView {
Label("downloads.empty.title", systemImage: "arrow.down.circle")
} description: {
Text("downloads.empty.description")
}
} else {
list
}
}
.background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.downloads"))
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
Text("downloads.removeConfirm"),
isPresented: Binding(
get: { itemPendingRemoval != nil },
set: { if !$0 { itemPendingRemoval = nil } }
),
titleVisibility: .visible
) {
Button("action.delete", role: .destructive) {
if let item = itemPendingRemoval {
Task { await downloads.cancel(item) }
}
itemPendingRemoval = nil
}
Button("action.cancel", role: .cancel) { itemPendingRemoval = nil }
}
}
private var list: some View {
List {
StorageSection()
// Erst was läuft, dann was fertig ist. Wer die Ansicht öffnet,
// während etwas lädt, will meistens genau das sehen.
if !active.isEmpty {
Section("downloads.active") {
ForEach(active) { item in
DownloadRow(item: item, onRemove: { itemPendingRemoval = item })
}
}
}
ForEach(completedGroups, id: \.title) { group in
Section(group.title) {
ForEach(group.items) { item in
DownloadRow(item: item, onRemove: { itemPendingRemoval = item })
.contentShape(.rect)
.onTapGesture { play(item) }
}
}
}
}
.scrollContentBackground(.hidden)
.listRowBackground(Color.flixySurface)
}
private var active: [DownloadItem] {
downloads.items.filter { $0.state != .completed }
}
/// Fertige Downloads, nach Serie gruppiert; Filme stehen zusammen.
private var completedGroups: [(title: String, items: [DownloadItem])] {
let completed = downloads.items.filter { $0.state == .completed }
let grouped = Dictionary(grouping: completed) {
$0.seriesTitle ?? String(localized: "downloads.movies")
}
return grouped
.map { (title: $0.key, items: $0.value.sorted(by: episodeOrder)) }
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
}
private func episodeOrder(_ lhs: DownloadItem, _ rhs: DownloadItem) -> Bool {
(lhs.seasonIndex ?? 0, lhs.episodeIndex ?? 0) < (rhs.seasonIndex ?? 0, rhs.episodeIndex ?? 0)
}
private func play(_ item: DownloadItem) {
guard item.state == .completed else { return }
playback.request = PlaybackRequest(
ratingKey: item.ratingKey,
title: item.title,
mediaIndex: 0,
startTime: .zero
)
}
}
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
private struct StorageSection: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
var body: some View {
@Bindable var downloads = downloads
Section {
HStack {
Text("downloads.used")
.foregroundStyle(Color.flixyTextSecondary)
Spacer()
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
.foregroundStyle(Color.flixyTextPrimary)
}
if let limit = downloads.storageLimitBytes, limit > 0 {
ProgressView(value: min(Double(downloads.usedBytes) / Double(limit), 1))
.tint(accent)
.accessibilityLabel(Text("downloads.used"))
}
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
}
}
}
/// Eine Zeile in der Downloads-Ansicht.
private struct DownloadRow: View {
let item: DownloadItem
let onRemove: () -> Void
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
HStack(spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle = item.subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
Spacer()
action
}
if item.state != .completed {
progress
}
if let message = item.failureMessage {
Text(message)
.font(.flixyCaption)
.foregroundStyle(Color.flixyDestructive)
.lineLimit(2)
}
}
.padding(.vertical, FlixySpacing.tight)
.listRowBackground(Color.flixySurface)
.swipeActions(edge: .trailing) {
Button("action.delete", role: .destructive, action: onRemove)
}
}
@ViewBuilder
private var action: some View {
switch item.state {
case .downloading:
Button {
Task { await downloads.pause(item) }
} label: {
Image(systemName: "pause.circle").font(.title3)
}
.buttonStyle(.plain)
.foregroundStyle(accent)
.accessibilityLabel(Text("downloads.pause"))
case .paused, .failed:
Button {
Task { await downloads.resume(item) }
} label: {
Image(systemName: "arrow.clockwise.circle").font(.title3)
}
.buttonStyle(.plain)
.foregroundStyle(accent)
.accessibilityLabel(Text("downloads.resume"))
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.flixyTextMuted)
.accessibilityLabel(Text("downloads.available"))
case .queued, .waiting:
Image(systemName: "clock")
.foregroundStyle(Color.flixyTextMuted)
.accessibilityLabel(Text("downloads.queued"))
}
}
private var progress: some View {
VStack(alignment: .leading, spacing: FlixySpacing.tight) {
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
// Download.
if let value = item.progress {
ProgressView(value: value).tint(accent)
} else {
ProgressView().progressViewStyle(.linear).tint(accent)
}
Text(statusLine)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
}
private var statusLine: String {
switch item.state {
case .downloading where item.totalBytes > 0:
"\(item.receivedBytes.formatted(.byteCount(style: .file))) / \(item.totalBytes.formatted(.byteCount(style: .file)))"
case .downloading:
item.receivedBytes.formatted(.byteCount(style: .file))
case .paused:
String(localized: "downloads.paused")
case .queued, .waiting:
String(localized: "downloads.queued")
case .failed:
String(localized: "downloads.failed")
case .completed:
item.totalBytes.formatted(.byteCount(style: .file))
}
}
}
+1 -1
View File
@@ -131,7 +131,7 @@ struct RootView: View {
case .liveTV:
PlaceholderScreen(destination: .liveTV)
case .downloads:
PlaceholderScreen(destination: .downloads)
DownloadsView()
case .settings:
SettingsView()
}
+71 -2
View File
@@ -1,5 +1,6 @@
import Foundation
import Observation
import FlixyDownloads
import FlixyPlayback
import FlixyVLC
import PlexKit
@@ -41,9 +42,13 @@ final class PlayerModel {
/// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: PlexItem?
private(set) var hasReachedEnd = false
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
private(set) var isOffline = false
private let request: PlaybackRequest
private let server: PlexServer?
private let downloads: DownloadCoordinator
private let policy: PlaybackPolicy
private let settings: PlaybackSettings
private let isLocalConnection: Bool
@@ -60,12 +65,14 @@ final class PlayerModel {
init(
request: PlaybackRequest,
server: PlexServer?,
downloads: DownloadCoordinator,
policy: PlaybackPolicy,
settings: PlaybackSettings,
isLocalConnection: Bool
) {
self.request = request
self.server = server
self.downloads = downloads
self.policy = policy
self.settings = settings
self.isLocalConnection = isLocalConnection
@@ -75,6 +82,14 @@ final class PlayerModel {
// MARK: - Start
func start() async {
// Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein
// Datenvolumen und funktioniert auch dann, wenn der Server gerade
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
if let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
await startLocal(url: localURL)
return
}
guard let server else {
phase = .failed(message: String(localized: "error.serverUnreachable"))
return
@@ -105,6 +120,59 @@ final class PlayerModel {
return
}
await load(source, decision: decision)
preloadNextEpisode()
}
/// Wiedergabe aus dem Download-Ordner.
///
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
/// dem Server lag. Welcher Motor sie öffnen kann, steht im Abzug, der beim
/// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen.
private func startLocal(url: URL) async {
let offline = downloads.offlineMedia(forRatingKey: request.ratingKey)
let decision = PlaybackDecider().decide(
media: PlexMedia(
id: 0,
container: offline?.container,
videoCodec: offline?.videoCodec,
audioCodec: offline?.audioCodec,
videoResolution: offline?.videoResolution,
bitrate: offline?.bitrate
),
part: PlexPart(
id: 0,
key: url.lastPathComponent,
container: offline?.container,
streams: offline.map(localStreams) ?? []
),
// Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze.
policy: .directPlayOnly
)
self.decision = decision
isOffline = true
await load(
PlaybackSource(url: url, origin: .local, startTime: request.startTime),
decision: decision
)
}
/// Baut künstliche Spuren aus dem Offline-Abzug.
///
/// Der `PlaybackDecider` erwartet Spuren, um überhaupt zu entscheiden —
/// eine leere Liste hieße „noch nicht analysiert" und schickte jede
/// heruntergeladene Datei vorsichtshalber an VLC.
private func localStreams(from offline: OfflineMedia) -> [PlexStream] {
var streams = [PlexStream(id: 1, streamType: 1, codec: offline.videoCodec)]
if let audioCodec = offline.audioCodec {
streams.append(PlexStream(id: 2, streamType: 2, codec: audioCodec, selected: true))
}
return streams
}
private func load(_ source: PlaybackSource, decision: PlaybackDecision) async {
let engine = makeEngine(for: decision)
self.engine = engine
observe(engine)
@@ -113,8 +181,7 @@ final class PlayerModel {
try await engine.load(source)
engine.play()
phase = .playing
startReporting()
preloadNextEpisode()
if !isOffline { startReporting() }
} catch {
phase = .failed(message: String(localized: "player.loadFailed"))
}
@@ -314,6 +381,8 @@ final class PlayerModel {
case .reachedEnd:
isPlaying = false
hasReachedEnd = true
// Grundlage fürs automatische Löschen nach dem Ansehen.
downloads.markWatched(ratingKey: request.ratingKey)
case .tracksResolved(let audio, let subtitle):
audioTracks = audio
subtitleTracks = subtitle
+3
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import PlexKit
@@ -11,6 +12,7 @@ struct PlayerView: View {
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
@State private var model: PlayerModel?
@@ -68,6 +70,7 @@ struct PlayerView: View {
let model = PlayerModel(
request: request,
server: session.server,
downloads: downloads,
policy: settings.policy(for: network),
settings: settings,
isLocalConnection: session.server?.connection.local ?? false