Von der Kachel direkt abspielen, auch bei Serien
Wer weiss, was er sehen will, klickte bisher auf die Kachel, wartete auf die Detailseite und drueckte dort „Abspielen\". Zwei Schritte fuer eine Absicht. Jetzt gibt es oben rechts einen Knopf — auf dem Mac beim Darueberfahren, auf iPad und iPhone ueber langes Druecken. Bei einer Serie ist die Absicht nicht „spiel die Serie\", sondern „mach da weiter, wo ich war\". Welche Folge das ist, entscheidet `EpisodeSelection`: die aelteste ungesehene, fortgesetzt falls angefangen, und von vorn, wenn alles gesehen ist. Ein reiner Typ mit achtzehn Tests — im App-Ziel gibt es kein Unit-Test-Ziel, und „er hat die falsche Folge gestartet\" ist sonst ein Befund, den man nicht nachstellen kann. Ausdruecklich die aelteste ungesehene, nicht die am weitesten fortgeschrittene: wer sich eine Folge aufgehoben hat, soll sie nicht dadurch verlieren, dass er weiter vorn hineingeschaut hat. Dazu `episodes(inSeries:)` im Vertrag — bei Jellyfin eine Anfrage statt dreizehn, fuer alle anderen die Vorgabe ueber `children(of:)`. Nicht `/Shows/NextUp`: der braucht eine Seriennummer, die eine Serie selbst nicht hat, versteckt vollstaendig gesehene Serien und bringt eine eigene Zeitgrenze mit. Welche Folge dran ist, soll eine Regel entscheiden, nicht der Server. Nebenbei behoben: der grosse Abspielknopf im Aufmacher scheiterte bisher mit „keine abspielbare Datei\", sobald dort eine Serie stand — und Jellyfins „Zuletzt hinzugefuegt\" liefert neue Folgen als ihre Serie. Der Knopf liegt als Ueberzug auf dem NavigationLink, nicht darin: ein Button im Label eines Links loest auf iOS nie aus, und `.plain` macht das Versagen lautlos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2a2afa8573
commit
a1ff5cb7bb
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import MediaKit
|
||||
|
||||
/// „Spiel das jetzt" — von einer Kachel aus, ohne Umweg über die Detailseite.
|
||||
///
|
||||
/// **Warum ein eigener Typ.** Bei einem Film ist nichts nachzuschlagen: er
|
||||
/// läuft sofort. Bei einer Serie schon — es muss erst feststehen, welche
|
||||
/// Folge dran ist, und das ist eine Serveranfrage. Diese Anfrage gehört
|
||||
/// weder in `PlaybackCoordinator`, der keinen Serverzugang hat und dessen
|
||||
/// Aufgabe die Darstellung ist, noch in die Kacheln, wo sie dreimal stünde.
|
||||
///
|
||||
/// **Die Regel selbst steht nicht hier**, sondern in `EpisodeSelection` — als
|
||||
/// reiner Typ mit Tests. Hier steht nur, woher die Liste kommt.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class QuickPlay {
|
||||
/// Welche Kachel gerade nachschlägt.
|
||||
///
|
||||
/// Eine Kennung, kein Wahrheitswert: der Kreisel soll an **dieser** einen
|
||||
/// Kachel stehen und nicht an allen zwanzig.
|
||||
private(set) var resolvingID: String?
|
||||
|
||||
/// Warum es nicht ging — einmal für die ganze App.
|
||||
///
|
||||
/// Ein Hinweis je Kachel wäre bei einem Serverausfall ein Stapel aus
|
||||
/// zwanzig identischen Meldungen.
|
||||
var failure: String?
|
||||
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
/// Startet, was hinter dieser Kachel steckt.
|
||||
///
|
||||
/// `backend` und `coordinator` kommen als Argumente statt als gespeicherte
|
||||
/// Werte: so bleibt der Typ ohne Abhängigkeiten baubar, und die Sitzung
|
||||
/// kann den Server wechseln, ohne dass hier etwas veraltet. Dasselbe
|
||||
/// Muster wie bei `DownloadRefiller`.
|
||||
func start(
|
||||
_ item: MediaItem,
|
||||
backend: (any MediaBackend)?,
|
||||
coordinator: PlaybackCoordinator
|
||||
) {
|
||||
// Der zweite Klick gewinnt. Sonst liefen zwei Abfragen, und die
|
||||
// langsamere überschriebe am Ende die gewollte.
|
||||
task?.cancel()
|
||||
|
||||
switch item.kind {
|
||||
case .show, .season:
|
||||
resolve(item, backend: backend, coordinator: coordinator)
|
||||
default:
|
||||
// **Sofort, ohne Aufgabe und ohne Kreisel.** Der häufige Fall
|
||||
// soll nicht aufblitzen, nur weil der seltene eine Anfrage
|
||||
// braucht.
|
||||
resolvingID = nil
|
||||
coordinator.play(item)
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
resolvingID = nil
|
||||
}
|
||||
|
||||
private func resolve(
|
||||
_ item: MediaItem,
|
||||
backend: (any MediaBackend)?,
|
||||
coordinator: PlaybackCoordinator
|
||||
) {
|
||||
guard let backend else {
|
||||
failure = String(localized: "quickPlay.noServer")
|
||||
return
|
||||
}
|
||||
|
||||
resolvingID = item.id
|
||||
task = Task { [weak self] in
|
||||
let folgen: [MediaItem]
|
||||
let bereich: EpisodeSelection.Scope
|
||||
do {
|
||||
if item.kind == .show {
|
||||
folgen = try await backend.episodes(inSeries: item.id)
|
||||
bereich = .series
|
||||
} else {
|
||||
// Eine Staffel ist schon eine Anfrage — `children(of:)`
|
||||
// genügt, und der Server muss nichts Besonderes können.
|
||||
folgen = try await backend.children(of: item.id)
|
||||
bereich = .season
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
resolvingID = nil
|
||||
failure = ErrorText.describe(error)
|
||||
return
|
||||
}
|
||||
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
resolvingID = nil
|
||||
|
||||
guard let wahl = EpisodeSelection.nextUp(in: folgen, scope: bereich) else {
|
||||
failure = String(localized: "quickPlay.nothingToPlay")
|
||||
return
|
||||
}
|
||||
coordinator.play(wahl.episode, resuming: wahl.resumes)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user