Wer im Fernsehen etwas sieht und nebenher nachsehen will, was sonst läuft, musste bisher abbrechen. Plex' eigene Handy-Apps können das auch nicht — kein Grund, es hier genauso zu machen. Der eigentliche Umbau ist nicht die Kachel, sondern der Besitz. Die Wiedergabe hing an der Sichtbarkeit der Ansicht, und zwar dreifach: das Modell war `@State` im Player, `onDisappear` hat den Motor abgeräumt, und die Bildebene entstand in `makeUIView` neu. Zurückgehen hieß deshalb: Motor tot, Transcoder aus, beim Live-TV der Tuner freigegeben. Jetzt gehört das Modell der Wurzelansicht, die immer da ist. Verkleinern und Vergrößern wechseln nur noch die Darstellung; beendet wird die Wiedergabe an genau einer Stelle, und zwar weil jemand es will. Die Bildebene hält das Modell selbst — dasselbe Muster, das VLC seit jeher verwendet. Dieselbe `UIView` wandert zwischen Kachel und Vollbild, statt neu zu entstehen; sonst setzte das Bild bei jedem Wechsel neu an. Bild-in-Bild wandert mit und bekommt endlich einen Delegaten: bisher war es eine Einbahnstraße, aus der man nicht in die App zurückkam. Die Kachel schwebt und lässt sich in jede Ecke ziehen, statt als Leiste unten zu kleben — eine Leiste nähme jeder Liste Platz weg und müsste in jedem Bildschirm bedacht werden. Die Eckenberechnung steht in MiniPlayerPlacement und ist geprüft: in einer View wäre eine Kachel, die halb aus dem Bild ragt, erst auf dem Gerät aufgefallen. Der Pfeil oben verkleinert, ein neuer Knopf daneben beendet wirklich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
11 KiB
Swift
312 lines
11 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import FlixyDownloads
|
|
import FlixyPlayback
|
|
|
|
/// Was offline ist und was gerade geladen wird.
|
|
struct DownloadsView: View {
|
|
@Environment(DownloadCoordinator.self) private var downloads
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(NetworkConditions.self) private var network
|
|
@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 }
|
|
}
|
|
}
|
|
|
|
/// Was die Übertragung gerade aufhält.
|
|
private var hold: TransferHold {
|
|
TransferHold.current(
|
|
isWiFiOnly: downloads.isWiFiOnly,
|
|
isReachable: network.isReachable,
|
|
isExpensive: network.isExpensive
|
|
)
|
|
}
|
|
|
|
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") {
|
|
// Die Hintergrund-Sitzung wartet in diesen Fällen von
|
|
// sich aus: kein Fehler, nur Stillstand. Ohne diesen
|
|
// Hinweis sieht ein wartender Download genauso aus wie
|
|
// ein hängender.
|
|
if hold.isHolding { HoldNotice(hold: hold) }
|
|
|
|
ForEach(active) { item in
|
|
DownloadRow(
|
|
item: item,
|
|
hold: hold,
|
|
onRemove: { itemPendingRemoval = item }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
ForEach(completedGroups, id: \.title) { group in
|
|
Section(group.title) {
|
|
ForEach(group.items) { item in
|
|
// Fertige Downloads wartet nichts mehr auf.
|
|
DownloadRow(item: item, hold: .none, 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.play(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 hold: TransferHold
|
|
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
|
|
}
|
|
|
|
// Nur bei dem, was tatsächlich warten würde. Ein vom Nutzer
|
|
// angehaltener Download wartet nicht auf WLAN, sondern auf ihn.
|
|
if hold.isHolding, item.state == .downloading || item.state == .queued {
|
|
Label(
|
|
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
|
|
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
|
|
)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// Der Hinweis über der Liste, wenn nichts läuft und nichts kaputt ist.
|
|
private struct HoldNotice: View {
|
|
let hold: TransferHold
|
|
|
|
@Environment(\.flixyAccent) private var accent
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
|
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
|
|
.font(.title3)
|
|
.foregroundStyle(accent)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
|
|
.font(.flixyCardTitle)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
|
|
Text(hold == .offline
|
|
? "downloads.hold.offline.description"
|
|
: "downloads.hold.wifi.description")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
}
|
|
}
|
|
.padding(.vertical, FlixySpacing.tight)
|
|
.listRowBackground(Color.flixySurface)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|