Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,320 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloDownloads
|
||||
import MediaKit
|
||||
|
||||
/// 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: MediaItem
|
||||
var mediaIndex: Int = 0
|
||||
/// Ob die Dateiangaben erst noch geholt werden müssen.
|
||||
///
|
||||
/// **Für Zeilen, die nur eine Zusammenfassung tragen.** Die Folgenliste
|
||||
/// einer Staffel kommt ohne Fassungen und ohne Dateigrößen — mit so einem
|
||||
/// Titel wäre die Qualitätsliste leer und die erwartete Größe null, was
|
||||
/// die Prüfung auf freien Speicherplatz stillschweigend überspringt.
|
||||
/// `DownloadService.downloadAll` holt jede Folge aus genau diesem Grund
|
||||
/// einzeln nach.
|
||||
///
|
||||
/// Geholt wird erst beim ersten Tipp: zwanzig Zeilen beim Erscheinen
|
||||
/// nachzuladen wären zwanzig Anfragen für einen Knopf, den man meistens
|
||||
/// nicht drückt.
|
||||
var loadsDetails: Bool = false
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
@State private var refusal: DownloadRefusal?
|
||||
@State private var showsQualityChoice = false
|
||||
/// Der nachgeholte Titel — gilt ab dann statt `item`.
|
||||
@State private var detailed: MediaItem?
|
||||
@State private var isLoadingDetails = false
|
||||
/// Die Stufe steht fest, die Tonspur fehlt noch.
|
||||
///
|
||||
/// **Weil die Wahl nach dem Herunterladen nicht mehr zu korrigieren ist.**
|
||||
/// Beim Umpacken legt der Server genau eine Tonspur in die Datei; welche,
|
||||
/// hat vorher die Abwägung entschieden — und bei einer Aufnahme mit
|
||||
/// Audiodeskription die falsche. Drei Gigabyte später gab es keinen Weg
|
||||
/// zurück.
|
||||
@State private var pendingQuality: DownloadQuality?
|
||||
|
||||
private var existing: DownloadItem? {
|
||||
downloads.items.first { $0.ratingKey == item.id }
|
||||
}
|
||||
|
||||
/// Der Titel, aus dem Fassungen und Größen gelesen werden.
|
||||
///
|
||||
/// Die Kennung ist in beiden dieselbe — nur die Dateiangaben fehlen dem
|
||||
/// einen.
|
||||
private var source: MediaItem { detailed ?? item }
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
act()
|
||||
} label: {
|
||||
Group {
|
||||
if isLoadingDetails {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label(label, systemImage: symbol)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.haloCardTitle)
|
||||
}
|
||||
}
|
||||
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
|
||||
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
|
||||
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
|
||||
.frame(width: 22, height: 22)
|
||||
.padding(HaloSpacing.snug)
|
||||
}
|
||||
.disabled(isLoadingDetails)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(existing?.state == .completed ? accent : Color.haloTextSecondary)
|
||||
.accessibilityLabel(Text(label))
|
||||
.confirmationDialog(
|
||||
Text("downloads.quality.title"),
|
||||
isPresented: $showsQualityChoice,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(choice.available) { quality in
|
||||
Button(choice.label(for: quality)) { chose(quality) }
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.quality.hint")
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("downloads.audio.title"),
|
||||
isPresented: Binding(
|
||||
get: { pendingQuality != nil },
|
||||
set: { if !$0 { pendingQuality = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(audioStreams) { stream in
|
||||
Button(audioLabel(stream)) {
|
||||
if let pendingQuality { enqueue(quality: pendingQuality, audioStream: stream) }
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.audio.hint")
|
||||
}
|
||||
.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: continues ? "play.circle" : "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: continues ? "downloads.resume" : "action.retry"
|
||||
case nil: "downloads.download"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob ein Tipp dort weitermacht, wo es aufgehört hat.
|
||||
///
|
||||
/// Ein gescheiterter Download hat seinen Wiederaufnahme-Datensatz nur,
|
||||
/// wenn ihn das Netz unterbrochen hat — hat der Server abgelehnt, fängt
|
||||
/// es von vorn an, und dann heißt der Knopf auch so.
|
||||
private var continues: Bool { existing?.resumeData != nil }
|
||||
|
||||
private func act() {
|
||||
guard let existing else {
|
||||
// Vor dem Laden fragen. Der Unterschied zwischen einem
|
||||
// 40-GB-Remux und 2 GB in 720p ist auf einem Telefon keine
|
||||
// Nebensache — und hinterher lässt er sich nicht mehr ändern.
|
||||
guard loadsDetails, detailed == nil else {
|
||||
showsQualityChoice = true
|
||||
return
|
||||
}
|
||||
Task { await loadDetailsThenAsk() }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Holt die Dateiangaben nach und fragt dann nach der Qualität.
|
||||
///
|
||||
/// Scheitert das Nachholen, wird trotzdem gefragt — dann steht nur
|
||||
/// „Original" zur Wahl, und die Ablehnung kommt mit einer Begründung aus
|
||||
/// `DownloadService`. Das ist besser als ein Knopf, der nach einem Tipp
|
||||
/// kommentarlos nichts tut.
|
||||
private func loadDetailsThenAsk() async {
|
||||
isLoadingDetails = true
|
||||
detailed = try? await session.backend?.item(id: item.id)
|
||||
isLoadingDetails = false
|
||||
showsQualityChoice = true
|
||||
}
|
||||
|
||||
private var service: DownloadService {
|
||||
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
|
||||
}
|
||||
|
||||
private var audioStreams: [MediaStream] {
|
||||
let media = source.versions.indices.contains(mediaIndex)
|
||||
? source.versions[mediaIndex] : source.preferredVersion
|
||||
return media?.files.first?.audioStreams ?? []
|
||||
}
|
||||
|
||||
/// Die Spur, die vorgeschlagen würde — gemerkte Sprache der Serie zuerst.
|
||||
private var recommended: MediaStream? {
|
||||
let media = source.versions.indices.contains(mediaIndex)
|
||||
? source.versions[mediaIndex] : source.preferredVersion
|
||||
guard let part = media?.files.first else { return nil }
|
||||
return service.preferredAudioStream(in: part, of: source)
|
||||
}
|
||||
|
||||
private func audioLabel(_ stream: MediaStream) -> String {
|
||||
stream == recommended
|
||||
? String(localized: "downloads.audio.recommended \(stream.name)")
|
||||
: stream.name
|
||||
}
|
||||
|
||||
/// Nach der Stufe: entweder fragen oder einreihen.
|
||||
private func chose(_ quality: DownloadQuality) {
|
||||
if service.needsAudioChoice(for: source, mediaIndex: mediaIndex, quality: quality) {
|
||||
pendingQuality = quality
|
||||
} else {
|
||||
enqueue(quality: quality, audioStream: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(quality: DownloadQuality, audioStream: MediaStream?) {
|
||||
pendingQuality = nil
|
||||
do {
|
||||
_ = try service.download(
|
||||
source, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream
|
||||
)
|
||||
} catch {
|
||||
refusal = error
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zur Wahl steht — siehe `DownloadQualityChoice`.
|
||||
private var choice: DownloadQualityChoice {
|
||||
DownloadQualityChoice(item: source, mediaIndex: mediaIndex, backend: session.backend)
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadQuality {
|
||||
var localizedName: String.LocalizationValue {
|
||||
switch self {
|
||||
case .original: "quality.original"
|
||||
case .fullHD: "quality.fullHD"
|
||||
case .hd: "quality.hd"
|
||||
case .low: "quality.lowRes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Knopf für eine ganze Staffel oder Serie.
|
||||
struct DownloadAllButton: View {
|
||||
let container: MediaItem
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.haloAccent) 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.haloTextSecondary)
|
||||
.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(
|
||||
backend: session.backend, coordinator: downloads, languages: languages
|
||||
)
|
||||
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 .noDownloadableFile(let reason):
|
||||
reason
|
||||
case .insufficientDeviceSpace(let required, let available):
|
||||
String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user