From ad556c90347be308394e0fe7a43490ebd7dc7864 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 2 Oct 2026 21:05:49 +0200 Subject: [PATCH] Ein Knopf an jeder Folge, und die Suche spielt nicht mehr sofort ab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Staffelansicht: Download je Folge.** Für den Fall, den du beschrieben hast — Staffel halb gesehen, Rest nachholen. Ein Tipp öffnet dieselbe Qualitätsauswahl wie auf der Detailseite. Der Knopf steht **neben** dem Link, nicht darin: ein `Button` im Label eines `NavigationLink` löst auf iOS nie aus, und `.plain` lässt das lautlos geschehen. Dieselbe Falle steht in `QuickPlayTile` schon beschrieben. Als Geschwister in einer Reihe teilt das Layout den Platz auf, und der Titel kann gar nicht erst unter dem Knopf verschwinden. Die Kinderliste trägt keine Dateiangaben — ohne Nachladen wäre die Qualitätsliste leer und die Prüfung auf freien Speicher übersprungen. `DownloadButton` holt sie deshalb beim ersten Tipp nach (`loadsDetails`), mit Kreisel und fester Breite, damit die Zeile nicht springt. Nicht beim Erscheinen: zwanzig Zeilen wären zwanzig Anfragen für einen Knopf, den man meistens nicht drückt. Nur bei Folgen. Für eine ganze Staffel gibt es den Knopf oben schon. **Suche: Antippen führt auf die Seite.** Die Suche baut dieselbe Reihe wie die Startseite und war damit auch dieselbe Geste. Wer sucht, weiß aber noch nicht, was er sehen will — der erste Treffer ist eine Vermutung des Servers. Abspielen steht weiter im Menü unter langem Drücken. Dabei aufgefallen: `SearchView` hinterlegte `flixyItemChanged` nicht, anders als Startseite und Mediathek. „Als gesehen markieren" wirkte dort am Server, aber die Kachel lud nie neu — es sah aus, als wäre nichts passiert. Im Simulator geprüft: Knopf an jeder Folge, Auswahl mit echten Größen („Original · HD · 906,9 MB"), Suchtreffer öffnet die Detailseite. iOS und tvOS bauen, 489 Tests grün. Co-Authored-By: Claude Opus 5 --- Flixy/Views/Components/QuickPlayTile.swift | 21 +++++- Flixy/Views/Detail/SeriesDetailView.swift | 33 ++++++++-- Flixy/Views/Downloads/DownloadButton.swift | 75 +++++++++++++++++++--- Flixy/Views/Home/MediaRow.swift | 4 +- Flixy/Views/Search/SearchView.swift | 13 +++- 5 files changed, 128 insertions(+), 18 deletions(-) diff --git a/Flixy/Views/Components/QuickPlayTile.swift b/Flixy/Views/Components/QuickPlayTile.swift index 836c2ae..b6208fb 100644 --- a/Flixy/Views/Components/QuickPlayTile.swift +++ b/Flixy/Views/Components/QuickPlayTile.swift @@ -16,6 +16,14 @@ import MediaKit /// und wäre für VoiceOver gar nicht mehr da. struct QuickPlayTile: View { let item: MediaItem + /// Ob ein Tipp auf die Detailseite führt, statt abzuspielen. + /// + /// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will — + /// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf + /// der Startseite richtig (die Kachel stand schon da, man kennt sie) und + /// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des + /// Servers, keine Entscheidung des Nutzers. + var opensDetails: Bool = false @ViewBuilder let card: () -> Card @Environment(FlixySession.self) private var session @@ -41,6 +49,13 @@ struct QuickPlayTile: View { private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) } + /// Wohin ein Tipp führt. + /// + /// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg + /// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine + /// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt. + private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable } + var body: some View { kachel .buttonStyle(.plain) @@ -73,7 +88,7 @@ struct QuickPlayTile: View { // nachsehen will, was das überhaupt ist, drückt lang. Am Mac // ist es umgekehrt: dort öffnet der Klick die Seite, und zum // Starten gibt es den Knopf beim Überfahren. - if !isMac { + if !tapOpensDetails { Button("hero.details", systemImage: "info.circle") { push(route) } Divider() } @@ -94,7 +109,7 @@ struct QuickPlayTile: View { // Film, und die Detailseite wäre sonst nur über ein Kontextmenü zu // erreichen, das eine Geste verlangt. .accessibilityAction(named: Text("hero.details")) { - if isMac { return } + if tapOpensDetails { return } push(route) } // **Nur der Fehler, kein Kreisel.** Wer im Kontextmenü tippt, sieht @@ -129,7 +144,7 @@ struct QuickPlayTile: View { /// aussehen. @ViewBuilder private var kachel: some View { - if isMac || !isPlayable { + if tapOpensDetails { NavigationLink(value: route) { card() } } else { Button { trigger() } label: { card() } diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 7cbdc85..b54606c 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -106,10 +106,33 @@ struct SeriesDetailView: View { LazyVStack(spacing: 0) { ForEach(entries) { entry in - NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) { - ChildRow(item: entry) + // **Der Knopf steht neben dem Link, nicht darin.** Ein + // `Button` im Label eines `NavigationLink` löst auf iOS + // nie aus — der Link macht sein Label zu einer einzigen + // Fläche, und `.plain` lässt das Versagen lautlos + // geschehen. Dieselbe Falle steht in `QuickPlayTile` + // beschrieben. + // + // Als Geschwister in einer Reihe statt als Überzug: + // dann teilt das Layout den Platz auf, und der Titel + // kann gar nicht erst unter dem Knopf verschwinden. + HStack(spacing: FlixySpacing.snug) { + NavigationLink( + value: MediaRoute(ratingKey: entry.id, title: entry.title) + ) { + ChildRow(item: entry) + } + .buttonStyle(.plain) + + // **Nur bei Folgen.** Für eine ganze Staffel gibt + // es den Knopf oben im Kopf der Seite; zweimal + // dasselbe Angebot auf einem Bildschirm ist keine + // Abkürzung, sondern eine Frage. + if entry.kind == .episode { + DownloadButton(item: entry, loadsDetails: true) + } } - .buttonStyle(.plain) + .padding(.horizontal, FlixySpacing.screenMargin) Divider().overlay(Color.flixySeparator) } @@ -191,7 +214,9 @@ private struct ChildRow: View { .accessibilityLabel(Text("item.watched")) } } - .padding(.horizontal, FlixySpacing.screenMargin) + // Das waagerechte Polster liegt jetzt eine Ebene höher, bei der + // Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am + // Bildschirmrand. .padding(.vertical, FlixySpacing.snug) .contentShape(.rect) .accessibilityElement(children: .combine) diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift index 0b17e26..8a9ae53 100644 --- a/Flixy/Views/Downloads/DownloadButton.swift +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -12,6 +12,19 @@ import PlexKit 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(FlixySession.self) private var session @Environment(DownloadCoordinator.self) private var downloads @@ -19,20 +32,40 @@ struct DownloadButton: View { @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 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: { - Label(label, systemImage: symbol) - .labelStyle(.iconOnly) - .font(.flixyCardTitle) - .padding(FlixySpacing.snug) + Group { + if isLoadingDetails { + ProgressView().controlSize(.small) + } else { + Label(label, systemImage: symbol) + .labelStyle(.iconOnly) + .font(.flixyCardTitle) + } + } + // 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(FlixySpacing.snug) } + .disabled(isLoadingDetails) .buttonStyle(.bordered) .tint(existing?.state == .completed ? accent : Color.flixyTextSecondary) .accessibilityLabel(Text(label)) @@ -63,7 +96,7 @@ struct DownloadButton: View { 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 .failed: continues ? "play.circle" : "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90" case nil: "arrow.down.circle" } } @@ -73,17 +106,28 @@ struct DownloadButton: View { case .completed: "downloads.available" case .downloading, .queued, .waiting: "downloads.cancel" case .paused: "downloads.resume" - case .failed: "action.retry" + 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. - showsQualityChoice = true + guard loadsDetails, detailed == nil else { + showsQualityChoice = true + return + } + Task { await loadDetailsThenAsk() } return } @@ -100,17 +144,30 @@ struct DownloadButton: View { } } + /// 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 func enqueue(quality: DownloadQuality) { let service = DownloadService(backend: session.backend, coordinator: downloads) do { - _ = try service.download(item, mediaIndex: mediaIndex, quality: quality) + _ = try service.download(source, mediaIndex: mediaIndex, quality: quality) } catch { refusal = error } } private var currentMedia: MediaVersion? { - item.versions.indices.contains(mediaIndex) ? item.versions[mediaIndex] : item.preferredVersion + source.versions.indices.contains(mediaIndex) ? source.versions[mediaIndex] : source.preferredVersion } /// Nur Stufen, die unter der Auflösung der Datei liegen. diff --git a/Flixy/Views/Home/MediaRow.swift b/Flixy/Views/Home/MediaRow.swift index 8d484ca..5030f23 100644 --- a/Flixy/Views/Home/MediaRow.swift +++ b/Flixy/Views/Home/MediaRow.swift @@ -5,6 +5,8 @@ import MediaKit /// Eine waagerechte Reihe von Titeln. struct MediaRow: View { let row: MediaRowGroup + /// Wird durchgereicht — siehe `QuickPlayTile`. + var opensDetails: Bool = false @Environment(FlixySession.self) private var session @@ -30,7 +32,7 @@ struct MediaRow: View { ScrollView(.horizontal) { LazyHStack(spacing: FlixySpacing.regular) { ForEach(row.items) { item in - QuickPlayTile(item: item) { card(for: item) } + QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) } } } .padding(.horizontal, FlixySpacing.screenMargin) diff --git a/Flixy/Views/Search/SearchView.swift b/Flixy/Views/Search/SearchView.swift index a0d51be..2aacb21 100644 --- a/Flixy/Views/Search/SearchView.swift +++ b/Flixy/Views/Search/SearchView.swift @@ -31,13 +31,24 @@ struct SearchView: View { // kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los, // von denen elf verworfen werden. .task(id: text) { await feed.search(text, from: session.backend) } + // **Hier fehlte er.** Startseite und Mediathek hinterlegen das längst; + // in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus + // dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah + // aus, als wäre nichts passiert. + .environment(\.flixyItemChanged, { await feed.search(text, from: session.backend) }) } private var results: some View { ScrollView { LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { ForEach(feed.hubs) { hub in - MediaRow(row: hub) + // **Antippen führt auf die Seite, nicht in den Film.** Die + // Suche benutzt dieselbe Reihe wie die Startseite — und + // damit war sie bisher auch dieselbe Geste. Wer sucht, + // weiß aber noch nicht, was er sehen will; der erste + // Treffer ist eine Vermutung des Servers. Starten geht + // weiter, über langes Drücken. + MediaRow(row: hub, opensDetails: true) } } .padding(.vertical, FlixySpacing.loose)