Ein Knopf an jeder Folge, und die Suche spielt nicht mehr sofort ab
**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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e86c0a6e95
commit
ad556c9034
@@ -16,6 +16,14 @@ import MediaKit
|
|||||||
/// und wäre für VoiceOver gar nicht mehr da.
|
/// und wäre für VoiceOver gar nicht mehr da.
|
||||||
struct QuickPlayTile<Card: View>: View {
|
struct QuickPlayTile<Card: View>: View {
|
||||||
let item: MediaItem
|
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
|
@ViewBuilder let card: () -> Card
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@@ -41,6 +49,13 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
|
|
||||||
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
|
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 {
|
var body: some View {
|
||||||
kachel
|
kachel
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -73,7 +88,7 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
|
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
|
||||||
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
|
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
|
||||||
// Starten gibt es den Knopf beim Überfahren.
|
// Starten gibt es den Knopf beim Überfahren.
|
||||||
if !isMac {
|
if !tapOpensDetails {
|
||||||
Button("hero.details", systemImage: "info.circle") { push(route) }
|
Button("hero.details", systemImage: "info.circle") { push(route) }
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
@@ -94,7 +109,7 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
// Film, und die Detailseite wäre sonst nur über ein Kontextmenü zu
|
// Film, und die Detailseite wäre sonst nur über ein Kontextmenü zu
|
||||||
// erreichen, das eine Geste verlangt.
|
// erreichen, das eine Geste verlangt.
|
||||||
.accessibilityAction(named: Text("hero.details")) {
|
.accessibilityAction(named: Text("hero.details")) {
|
||||||
if isMac { return }
|
if tapOpensDetails { return }
|
||||||
push(route)
|
push(route)
|
||||||
}
|
}
|
||||||
// **Nur der Fehler, kein Kreisel.** Wer im Kontextmenü tippt, sieht
|
// **Nur der Fehler, kein Kreisel.** Wer im Kontextmenü tippt, sieht
|
||||||
@@ -129,7 +144,7 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
/// aussehen.
|
/// aussehen.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var kachel: some View {
|
private var kachel: some View {
|
||||||
if isMac || !isPlayable {
|
if tapOpensDetails {
|
||||||
NavigationLink(value: route) { card() }
|
NavigationLink(value: route) { card() }
|
||||||
} else {
|
} else {
|
||||||
Button { trigger() } label: { card() }
|
Button { trigger() } label: { card() }
|
||||||
|
|||||||
@@ -106,10 +106,33 @@ struct SeriesDetailView: View {
|
|||||||
|
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(entries) { entry in
|
ForEach(entries) { entry in
|
||||||
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
|
// **Der Knopf steht neben dem Link, nicht darin.** Ein
|
||||||
ChildRow(item: entry)
|
// `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)
|
Divider().overlay(Color.flixySeparator)
|
||||||
}
|
}
|
||||||
@@ -191,7 +214,9 @@ private struct ChildRow: View {
|
|||||||
.accessibilityLabel(Text("item.watched"))
|
.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)
|
.padding(.vertical, FlixySpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ import PlexKit
|
|||||||
struct DownloadButton: View {
|
struct DownloadButton: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
var mediaIndex: Int = 0
|
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(FlixySession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@@ -19,20 +32,40 @@ struct DownloadButton: View {
|
|||||||
|
|
||||||
@State private var refusal: DownloadRefusal?
|
@State private var refusal: DownloadRefusal?
|
||||||
@State private var showsQualityChoice = false
|
@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? {
|
private var existing: DownloadItem? {
|
||||||
downloads.items.first { $0.ratingKey == item.id }
|
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 {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
act()
|
act()
|
||||||
} label: {
|
} label: {
|
||||||
Label(label, systemImage: symbol)
|
Group {
|
||||||
.labelStyle(.iconOnly)
|
if isLoadingDetails {
|
||||||
.font(.flixyCardTitle)
|
ProgressView().controlSize(.small)
|
||||||
.padding(FlixySpacing.snug)
|
} 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)
|
.buttonStyle(.bordered)
|
||||||
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
|
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
|
||||||
.accessibilityLabel(Text(label))
|
.accessibilityLabel(Text(label))
|
||||||
@@ -63,7 +96,7 @@ struct DownloadButton: View {
|
|||||||
case .completed: "checkmark.circle.fill"
|
case .completed: "checkmark.circle.fill"
|
||||||
case .downloading, .queued, .waiting: "stop.circle"
|
case .downloading, .queued, .waiting: "stop.circle"
|
||||||
case .paused: "play.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"
|
case nil: "arrow.down.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,17 +106,28 @@ struct DownloadButton: View {
|
|||||||
case .completed: "downloads.available"
|
case .completed: "downloads.available"
|
||||||
case .downloading, .queued, .waiting: "downloads.cancel"
|
case .downloading, .queued, .waiting: "downloads.cancel"
|
||||||
case .paused: "downloads.resume"
|
case .paused: "downloads.resume"
|
||||||
case .failed: "action.retry"
|
case .failed: continues ? "downloads.resume" : "action.retry"
|
||||||
case nil: "downloads.download"
|
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() {
|
private func act() {
|
||||||
guard let existing else {
|
guard let existing else {
|
||||||
// Vor dem Laden fragen. Der Unterschied zwischen einem
|
// Vor dem Laden fragen. Der Unterschied zwischen einem
|
||||||
// 40-GB-Remux und 2 GB in 720p ist auf einem Telefon keine
|
// 40-GB-Remux und 2 GB in 720p ist auf einem Telefon keine
|
||||||
// Nebensache — und hinterher lässt er sich nicht mehr ändern.
|
// Nebensache — und hinterher lässt er sich nicht mehr ändern.
|
||||||
showsQualityChoice = true
|
guard loadsDetails, detailed == nil else {
|
||||||
|
showsQualityChoice = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { await loadDetailsThenAsk() }
|
||||||
return
|
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) {
|
private func enqueue(quality: DownloadQuality) {
|
||||||
let service = DownloadService(backend: session.backend, coordinator: downloads)
|
let service = DownloadService(backend: session.backend, coordinator: downloads)
|
||||||
do {
|
do {
|
||||||
_ = try service.download(item, mediaIndex: mediaIndex, quality: quality)
|
_ = try service.download(source, mediaIndex: mediaIndex, quality: quality)
|
||||||
} catch {
|
} catch {
|
||||||
refusal = error
|
refusal = error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentMedia: MediaVersion? {
|
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.
|
/// Nur Stufen, die unter der Auflösung der Datei liegen.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import MediaKit
|
|||||||
/// Eine waagerechte Reihe von Titeln.
|
/// Eine waagerechte Reihe von Titeln.
|
||||||
struct MediaRow: View {
|
struct MediaRow: View {
|
||||||
let row: MediaRowGroup
|
let row: MediaRowGroup
|
||||||
|
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
||||||
|
var opensDetails: Bool = false
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ struct MediaRow: View {
|
|||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
LazyHStack(spacing: FlixySpacing.regular) {
|
LazyHStack(spacing: FlixySpacing.regular) {
|
||||||
ForEach(row.items) { item in
|
ForEach(row.items) { item in
|
||||||
QuickPlayTile(item: item) { card(for: item) }
|
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||||
|
|||||||
@@ -31,13 +31,24 @@ struct SearchView: View {
|
|||||||
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
|
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
|
||||||
// von denen elf verworfen werden.
|
// von denen elf verworfen werden.
|
||||||
.task(id: text) { await feed.search(text, from: session.backend) }
|
.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 {
|
private var results: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||||
ForEach(feed.hubs) { hub in
|
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)
|
.padding(.vertical, FlixySpacing.loose)
|
||||||
|
|||||||
Reference in New Issue
Block a user