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:
@@ -96,6 +96,8 @@ struct PlayableDetailView: View {
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.flixyTextSecondary)
|
||||
}
|
||||
|
||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ struct SeriesDetailView: View {
|
||||
height: 1200
|
||||
)
|
||||
) {
|
||||
EmptyView()
|
||||
DownloadAllButton(container: item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ struct RootView: View {
|
||||
case .liveTV:
|
||||
PlaceholderScreen(destination: .liveTV)
|
||||
case .downloads:
|
||||
PlaceholderScreen(destination: .downloads)
|
||||
DownloadsView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user