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
@@ -29,6 +29,7 @@ struct FlixyApp: App {
|
|||||||
@State private var playback = PlaybackCoordinator()
|
@State private var playback = PlaybackCoordinator()
|
||||||
@State private var playbackSettings = PlaybackSettings()
|
@State private var playbackSettings = PlaybackSettings()
|
||||||
@State private var languages = LanguagePreferences()
|
@State private var languages = LanguagePreferences()
|
||||||
|
@State private var quickPlay = QuickPlay()
|
||||||
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
|
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
|
||||||
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
@@ -88,6 +89,7 @@ struct FlixyApp: App {
|
|||||||
.environment(playback)
|
.environment(playback)
|
||||||
.environment(playbackSettings)
|
.environment(playbackSettings)
|
||||||
.environment(languages)
|
.environment(languages)
|
||||||
|
.environment(quickPlay)
|
||||||
.environment(network)
|
.environment(network)
|
||||||
.environment(downloads)
|
.environment(downloads)
|
||||||
.environment(homeLayout)
|
.environment(homeLayout)
|
||||||
|
|||||||
@@ -4519,6 +4519,57 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quickPlay.failed": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Abspielen nicht möglich"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Can’t play this"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickPlay.noServer": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Keine Verbindung zum Server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Not connected to the server."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickPlay.nothingToPlay": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Hier ist keine Folge zu finden, die sich abspielen lässt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "There’s no episode here that could be played."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"runtime.hoursMinutes %lld %lld": {
|
"runtime.hoursMinutes %lld %lld": {
|
||||||
"comment": "Laufzeit mit Stunden",
|
"comment": "Laufzeit mit Stunden",
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
@@ -6821,6 +6872,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tile.play": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Abspielen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Play"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.1"
|
"version": "1.1"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlixyDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
|
||||||
|
/// startet.
|
||||||
|
///
|
||||||
|
/// **Der Umweg, den es spart.** Wer weiß, was er sehen will, klickte bisher
|
||||||
|
/// auf die Kachel, wartete auf die Detailseite und drückte dort „Abspielen".
|
||||||
|
/// Zwei Schritte für eine Absicht.
|
||||||
|
///
|
||||||
|
/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt
|
||||||
|
/// in `FlixyDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es
|
||||||
|
/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der
|
||||||
|
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
|
||||||
|
/// und wäre für VoiceOver gar nicht mehr da.
|
||||||
|
struct QuickPlayTile<Card: View>: View {
|
||||||
|
let item: MediaItem
|
||||||
|
@ViewBuilder let card: () -> Card
|
||||||
|
|
||||||
|
@Environment(FlixySession.self) private var session
|
||||||
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
|
@Environment(QuickPlay.self) private var quickPlay
|
||||||
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
|
@State private var isPointerInside = false
|
||||||
|
|
||||||
|
private var isMac: Bool { FlixyPlatform.isMac }
|
||||||
|
|
||||||
|
/// Ob es überhaupt etwas zu starten gibt.
|
||||||
|
///
|
||||||
|
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
||||||
|
private var isPlayable: Bool { item.kind != .collection }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||||
|
card()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
// **Als Überzug auf dem Link, nicht als Kind darin.**
|
||||||
|
//
|
||||||
|
// Ein `Button` im *Label* eines `NavigationLink` löst auf iOS nie aus:
|
||||||
|
// der Link macht sein Label zu einer einzigen Fläche, verschachtelte
|
||||||
|
// Bedienelemente werden nicht eigens getroffen — und `.plain` macht
|
||||||
|
// das Versagen lautlos. Als Geschwister darüber gewinnt er.
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if isPlayable, isMac { playButton }
|
||||||
|
}
|
||||||
|
.onContinuousHover { phase in
|
||||||
|
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
||||||
|
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
||||||
|
guard isMac else { return }
|
||||||
|
if case .active = phase { isPointerInside = true } else { isPointerInside = false }
|
||||||
|
}
|
||||||
|
.animation(.easeOut(duration: 0.15), value: isPointerInside)
|
||||||
|
// Der Weg ohne Zeiger: langes Drücken auf dem iPad, Rechtsklick auf
|
||||||
|
// dem Mac. Später der natürliche Ort für „Als gesehen markieren".
|
||||||
|
.contextMenu {
|
||||||
|
if isPlayable {
|
||||||
|
Button("tile.play", systemImage: "play.fill") { trigger() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Für VoiceOver eine **Aktion**, kein zweites Element: die Kachel
|
||||||
|
// bleibt einer, und im Aktionen-Rotor steht „Abspielen". Zwanzig
|
||||||
|
// Kacheln mit je zwei Elementen wären sechzig Wischer.
|
||||||
|
.accessibilityAction(named: Text("tile.play")) { trigger() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oben rechts, weil der Fortschrittsbalken unten liegt.
|
||||||
|
private var playButton: some View {
|
||||||
|
Button(action: trigger) {
|
||||||
|
Group {
|
||||||
|
if quickPlay.resolvingID == item.id {
|
||||||
|
ProgressView().controlSize(.small).tint(.white)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Feste Grösse, damit der Kreisel den Knopf nicht wachsen lässt
|
||||||
|
// und nichts springt, wenn nachgeschlagen wird.
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(accent.opacity(0.92), in: .circle)
|
||||||
|
.shadow(color: .black.opacity(0.35), radius: 3, y: 1)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(FlixySpacing.snug)
|
||||||
|
.opacity(isPointerInside ? 1 : 0)
|
||||||
|
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
|
||||||
|
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trigger() {
|
||||||
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ struct HeroSection: View {
|
|||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
|
@Environment(QuickPlay.self) private var quickPlay
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
@@ -30,7 +31,12 @@ struct HeroSection: View {
|
|||||||
fillsAvailableSpace: sizeClass != .compact
|
fillsAvailableSpace: sizeClass != .compact
|
||||||
) {
|
) {
|
||||||
Button {
|
Button {
|
||||||
playback.play(item)
|
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
||||||
|
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
||||||
|
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
||||||
|
// deren eigene Kennung an den Player, und der scheiterte mit
|
||||||
|
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
||||||
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||||
} label: {
|
} label: {
|
||||||
Label(playLabel, systemImage: "play.fill")
|
Label(playLabel, systemImage: "play.fill")
|
||||||
.font(.flixyCardTitle)
|
.font(.flixyCardTitle)
|
||||||
|
|||||||
@@ -30,10 +30,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
|
||||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
QuickPlayTile(item: item) { card(for: item) }
|
||||||
card(for: item)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ struct LibraryView: View {
|
|||||||
spacing: FlixySpacing.loose
|
spacing: FlixySpacing.loose
|
||||||
) {
|
) {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
QuickPlayTile(item: item) {
|
||||||
PosterCard(
|
PosterCard(
|
||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: item.cardSubtitle,
|
||||||
@@ -107,7 +107,6 @@ struct LibraryView: View {
|
|||||||
progress: item.watchProgress
|
progress: item.watchProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.task { await loadMoreIfNeeded(reaching: item) }
|
.task { await loadMoreIfNeeded(reaching: item) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct RootView: View {
|
|||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
|
@Environment(QuickPlay.self) private var quickPlay
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
@@ -121,6 +122,19 @@ struct RootView: View {
|
|||||||
// zuverlässig neu aus, ein Scene-Body nicht.
|
// zuverlässig neu aus, ein Scene-Body nicht.
|
||||||
.flixyAccent(theme)
|
.flixyAccent(theme)
|
||||||
.tint(theme.accentColor)
|
.tint(theme.accentColor)
|
||||||
|
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
|
||||||
|
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
|
||||||
|
.alert(
|
||||||
|
Text("quickPlay.failed"),
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { quickPlay.failure != nil },
|
||||||
|
set: { if !$0 { quickPlay.failure = nil } }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Button("action.close", role: .cancel) { quickPlay.failure = nil }
|
||||||
|
} message: {
|
||||||
|
if let grund = quickPlay.failure { Text(grund) }
|
||||||
|
}
|
||||||
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
|
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
|
||||||
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
|
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
|
||||||
// Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und
|
// Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und
|
||||||
|
|||||||
@@ -252,6 +252,24 @@ public func isInLocalNetwork() async -> Bool {
|
|||||||
return item.asMediaItem(imageBase: images)
|
return item.asMediaItem(imageBase: images)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alle Folgen einer Serie in **einer** Anfrage.
|
||||||
|
///
|
||||||
|
/// **Nicht `/Shows/NextUp`.** Der braucht `seriesID`, das bei einer Serie
|
||||||
|
/// selbst `nil` ist, versteckt vollständig gesehene Serien und bringt
|
||||||
|
/// eine eigene Zeitgrenze mit. Dann antworteten Plex und Jellyfin
|
||||||
|
/// verschieden auf dieselbe Frage — und welche Folge dran ist, soll eine
|
||||||
|
/// Regel entscheiden, nicht der Server.
|
||||||
|
///
|
||||||
|
/// `EnableUserData` ausdrücklich: ohne den Sehfortschritt wäre jede Folge
|
||||||
|
/// ungesehen, und es begänne immer bei der ersten.
|
||||||
|
public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
|
try await rowItems(path: "/Shows/\(id)/Episodes", query: [
|
||||||
|
URLQueryItem(name: "userId", value: account.userID),
|
||||||
|
URLQueryItem(name: "EnableUserData", value: "true"),
|
||||||
|
URLQueryItem(name: "Fields", value: Self.fields),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
|
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
try await rowItems(path: "/Users/\(account.userID)/Items", query: [
|
try await rowItems(path: "/Users/\(account.userID)/Items", query: [
|
||||||
URLQueryItem(name: "ParentId", value: id),
|
URLQueryItem(name: "ParentId", value: id),
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Welche Folge dran ist, wenn jemand eine Serie oder Staffel startet.
|
||||||
|
///
|
||||||
|
/// **Warum als reiner Typ.** Liste hinein, eine Folge heraus. Kein Netz, keine
|
||||||
|
/// Einstellungen, keine Ansicht — dieselbe Bauform wie `HeroSelection` und
|
||||||
|
/// `AudioTrackChooser`, und aus demselben Grund: im App-Ziel gibt es kein
|
||||||
|
/// Unit-Test-Ziel, also muss alles, worin eine Entscheidung steckt, hierher.
|
||||||
|
/// „Er hat die falsche Folge gestartet" ist sonst ein Befund, den man nicht
|
||||||
|
/// nachstellen kann.
|
||||||
|
///
|
||||||
|
/// **Die Regel in einem Satz:** die älteste Folge, die noch nicht als gesehen
|
||||||
|
/// gilt; wurde sie angefangen, dort weiter; ist alles gesehen, von vorn.
|
||||||
|
public enum EpisodeSelection {
|
||||||
|
/// Ob die Liste eine ganze Serie ist oder genau eine Staffel.
|
||||||
|
///
|
||||||
|
/// Sie entscheidet nur zweierlei: ob Specials wegfallen und was „von vorn"
|
||||||
|
/// bedeutet. Alles Übrige ist gleich.
|
||||||
|
public enum Scope: Sendable, Equatable {
|
||||||
|
case series
|
||||||
|
case season
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was gespielt werden soll — und ob an der gemerkten Stelle.
|
||||||
|
public struct Choice: Sendable, Equatable {
|
||||||
|
public let episode: MediaItem
|
||||||
|
/// Ob an der gemerkten Stelle fortgesetzt wird.
|
||||||
|
public let resumes: Bool
|
||||||
|
|
||||||
|
public init(episode: MediaItem, resumes: Bool) {
|
||||||
|
self.episode = episode
|
||||||
|
self.resumes = resumes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ab wo eine angefangene Folge **nicht** mehr fortgesetzt wird.
|
||||||
|
///
|
||||||
|
/// Ein Server, der einen Fortschritt vier Sekunden vor Schluss noch nicht
|
||||||
|
/// gelöscht hat, öffnete den Player sonst mitten im Abspann — und das
|
||||||
|
/// sieht aus wie „er hat die Folge übersprungen".
|
||||||
|
static let resumeCeiling = 0.95
|
||||||
|
|
||||||
|
/// Die Folge, mit der es weitergeht.
|
||||||
|
///
|
||||||
|
/// `nil`, wenn nach dem Aussortieren nichts übrig ist. Dann sagt der
|
||||||
|
/// Aufrufer das, statt lautlos nichts zu tun.
|
||||||
|
public static func nextUp(in items: [MediaItem], scope: Scope) -> Choice? {
|
||||||
|
let sortiert = ordered(items, scope: scope)
|
||||||
|
guard !sortiert.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// **Die älteste ungesehene, nicht die am weitesten fortgeschrittene.**
|
||||||
|
//
|
||||||
|
// Bei S1E1 gesehen, S1E2 unberührt und S1E5 halb gesehen kommt S1E2.
|
||||||
|
// Wer sich eine Folge für später aufgehoben hat, soll sie nicht
|
||||||
|
// dadurch verlieren, dass er irgendwo weiter vorn hineingeschaut hat.
|
||||||
|
guard let offen = sortiert.first(where: { !$0.isWatched }) else {
|
||||||
|
// Alles gesehen: von vorn. Bei einer Serie ist das die erste Folge
|
||||||
|
// der ersten Staffel, bei einer Staffel deren erste — derselbe
|
||||||
|
// Code, weil `scope` längst entschieden hat, was in der Liste
|
||||||
|
// liegt.
|
||||||
|
return Choice(episode: sortiert[0], resumes: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
let angefangen = (offen.watchProgress ?? 0) > 0
|
||||||
|
&& (offen.watchProgress ?? 0) < resumeCeiling
|
||||||
|
return Choice(episode: offen, resumes: angefangen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Sendereihenfolge — **nie** die des Servers.
|
||||||
|
///
|
||||||
|
/// Jellyfins `children(of:)` sortiert nach `SortName`, und das stellt
|
||||||
|
/// „Folge 10" vor „Folge 2". Sich darauf zu verlassen ginge genau so
|
||||||
|
/// lange gut, bis eine Staffel zehn Folgen hat.
|
||||||
|
static func ordered(_ items: [MediaItem], scope: Scope) -> [MediaItem] {
|
||||||
|
var übrig = items.filter { eintrag in
|
||||||
|
switch eintrag.kind {
|
||||||
|
// Behälter sind keine Folgen. `nil` bleibt dagegen: Jellyfin
|
||||||
|
// liefert in Listen nicht immer eine brauchbare Art, und
|
||||||
|
// Wegwerfen leerte die Liste.
|
||||||
|
case .season, .show, .collection: false
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Specials nur bei der Serie aussortieren.** Staffel 0 vorzuziehen
|
||||||
|
// wäre falsch — sie steht vor Staffel 1 und ist trotzdem nicht der
|
||||||
|
// Anfang. Wer dagegen die Specials-Kachel angeklickt hat, meint die
|
||||||
|
// Specials.
|
||||||
|
if scope == .series {
|
||||||
|
übrig = übrig.filter { ($0.parentIndex ?? 1) != 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fehlt eine Nummer, ist das keine Null, sondern „der Server hat
|
||||||
|
// nichts gesagt". Solche Einträge gehören ans Ende, nicht an den
|
||||||
|
// Anfang. Die Kennung zuletzt, damit dieselbe Eingabe dasselbe ergibt
|
||||||
|
// — sonst ist ein Fehler nicht wiederholbar.
|
||||||
|
übrig.sort { links, rechts in
|
||||||
|
let staffelL = links.parentIndex ?? .max
|
||||||
|
let staffelR = rechts.parentIndex ?? .max
|
||||||
|
if staffelL != staffelR { return staffelL < staffelR }
|
||||||
|
|
||||||
|
let folgeL = links.index ?? .max
|
||||||
|
let folgeR = rechts.index ?? .max
|
||||||
|
if folgeL != folgeR { return folgeL < folgeR }
|
||||||
|
|
||||||
|
return links.id < rechts.id
|
||||||
|
}
|
||||||
|
return übrig
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,13 @@ public protocol MediaBackend: Sendable {
|
|||||||
inLibrary library: MediaLibrary,
|
inLibrary library: MediaLibrary,
|
||||||
field: MediaQuery.Field
|
field: MediaQuery.Field
|
||||||
) async throws(MediaBackendError) -> [MediaFilterOption]
|
) async throws(MediaBackendError) -> [MediaFilterOption]
|
||||||
|
/// Alle Folgen einer Serie, über alle Staffeln hinweg.
|
||||||
|
///
|
||||||
|
/// **Als eigene Frage, weil der allgemeine Weg zu teuer ist.** Staffeln
|
||||||
|
/// holen und dann jede Staffel einzeln kostet bei zwölf Staffeln
|
||||||
|
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
||||||
|
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
||||||
|
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
||||||
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
||||||
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||||
|
|
||||||
@@ -159,6 +166,22 @@ public struct MediaPage: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public extension MediaBackend {
|
public extension MediaBackend {
|
||||||
|
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||||
|
///
|
||||||
|
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
||||||
|
///
|
||||||
|
/// **Ohne Sortieren und ohne Filtern.** Beides ist Sache von
|
||||||
|
/// `EpisodeSelection`, und es zweimal zu tun hiesse, dass die beiden
|
||||||
|
/// irgendwann auseinanderlaufen.
|
||||||
|
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
|
let staffeln = try await children(of: id)
|
||||||
|
var folgen: [MediaItem] = []
|
||||||
|
for staffel in staffeln {
|
||||||
|
folgen += (try? await children(of: staffel.id)) ?? []
|
||||||
|
}
|
||||||
|
return folgen
|
||||||
|
}
|
||||||
|
|
||||||
/// Ohne eigene Antwort keine Mediathek.
|
/// Ohne eigene Antwort keine Mediathek.
|
||||||
///
|
///
|
||||||
/// Plex beantwortet das heute nicht, und eine falsche Antwort wäre
|
/// Plex beantwortet das heute nicht, und eine falsche Antwort wäre
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import MediaKit
|
||||||
|
|
||||||
|
@Suite("Welche Folge dran ist")
|
||||||
|
struct EpisodeSelectionTests {
|
||||||
|
|
||||||
|
/// Eine Folge, so knapp wie möglich. `duration` steht dabei, weil
|
||||||
|
/// `watchProgress` ohne sie immer `nil` ist — genau die Falle, die einen
|
||||||
|
/// Test lautlos wirkungslos macht.
|
||||||
|
private func folge(
|
||||||
|
_ staffel: Int?,
|
||||||
|
_ nummer: Int?,
|
||||||
|
gesehen: Bool = false,
|
||||||
|
fortschritt: Double? = nil,
|
||||||
|
id: String? = nil
|
||||||
|
) -> MediaItem {
|
||||||
|
MediaItem(
|
||||||
|
id: id ?? "s\(staffel.map(String.init) ?? "x")e\(nummer.map(String.init) ?? "x")",
|
||||||
|
kind: .episode,
|
||||||
|
title: "Folge \(nummer.map(String.init) ?? "?")",
|
||||||
|
duration: 1_000,
|
||||||
|
index: nummer,
|
||||||
|
parentIndex: staffel,
|
||||||
|
playCount: gesehen ? 1 : 0,
|
||||||
|
resumeMilliseconds: fortschritt.map { Int($0 * 1_000) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Der Normalfall
|
||||||
|
|
||||||
|
/// Der Fall aus der Beschreibung: vier Folgen gesehen, die fünfte ist
|
||||||
|
/// dran.
|
||||||
|
@Test("Die älteste ungesehene Folge")
|
||||||
|
func firstUnwatched() {
|
||||||
|
let staffel = (1...6).map { folge(1, $0, gesehen: $0 <= 4) }
|
||||||
|
let wahl = EpisodeSelection.nextUp(in: staffel, scope: .season)
|
||||||
|
|
||||||
|
#expect(wahl?.episode.id == "s1e5")
|
||||||
|
#expect(wahl?.resumes == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Über Staffeln hinweg gilt dieselbe Regel")
|
||||||
|
func crossesSeasons() {
|
||||||
|
let serie = (1...3).map { folge(1, $0, gesehen: true) }
|
||||||
|
+ (1...3).map { folge(2, $0, gesehen: $0 == 1) }
|
||||||
|
let wahl = EpisodeSelection.nextUp(in: serie, scope: .series)
|
||||||
|
|
||||||
|
#expect(wahl?.episode.id == "s2e2")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drei Staffeln durch, in der vierten bei Folge zwei — der zweite Fall,
|
||||||
|
/// den der Nutzer ausdrücklich genannt hat.
|
||||||
|
@Test("Drei Staffeln durch, in der vierten weiter")
|
||||||
|
func deepIntoASeries() {
|
||||||
|
var serie: [MediaItem] = []
|
||||||
|
for staffel in 1...3 { serie += (1...8).map { folge(staffel, $0, gesehen: true) } }
|
||||||
|
serie += [folge(4, 1, gesehen: true)] + (2...8).map { folge(4, $0) }
|
||||||
|
serie += (1...8).map { folge(5, $0) }
|
||||||
|
|
||||||
|
#expect(EpisodeSelection.nextUp(in: serie, scope: .series)?.episode.id == "s4e2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fortsetzen
|
||||||
|
|
||||||
|
@Test("Eine angefangene Folge wird fortgesetzt")
|
||||||
|
func resumesPartial() {
|
||||||
|
let staffel = [folge(1, 1, gesehen: true), folge(1, 2, fortschritt: 0.4)]
|
||||||
|
let wahl = EpisodeSelection.nextUp(in: staffel, scope: .season)
|
||||||
|
|
||||||
|
#expect(wahl?.episode.id == "s1e2")
|
||||||
|
#expect(wahl?.resumes == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Fortschritt, den der Server kurz vor Schluss noch nicht gelöscht
|
||||||
|
/// hat, öffnete den Player sonst mitten im Abspann — und das sieht aus,
|
||||||
|
/// als hätte er die Folge übersprungen.
|
||||||
|
@Test("Kurz vor Schluss wird nicht fortgesetzt")
|
||||||
|
func doesNotResumeAtTheVeryEnd() {
|
||||||
|
let staffel = [folge(1, 1, fortschritt: 0.98)]
|
||||||
|
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.resumes == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne Fortschritt wird von vorn begonnen")
|
||||||
|
func startsFromZero() {
|
||||||
|
#expect(EpisodeSelection.nextUp(in: [folge(1, 1)], scope: .season)?.resumes == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Die älteste ungesehene, nicht die am weitesten fortgeschrittene.**
|
||||||
|
/// Wer sich eine Folge aufgehoben hat, soll sie nicht dadurch verlieren,
|
||||||
|
/// dass er weiter vorn hineingeschaut hat.
|
||||||
|
@Test("Eine spätere angefangene Folge zieht nicht vor")
|
||||||
|
func laterProgressDoesNotWin() {
|
||||||
|
let staffel = [
|
||||||
|
folge(1, 1, gesehen: true),
|
||||||
|
folge(1, 2),
|
||||||
|
folge(1, 5, fortschritt: 0.5),
|
||||||
|
]
|
||||||
|
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Alles gesehen
|
||||||
|
|
||||||
|
@Test("Ist alles gesehen, beginnt die Serie von vorn")
|
||||||
|
func watchedSeriesRestarts() {
|
||||||
|
let serie = (1...3).map { folge(1, $0, gesehen: true) }
|
||||||
|
+ (1...3).map { folge(2, $0, gesehen: true) }
|
||||||
|
let wahl = EpisodeSelection.nextUp(in: serie, scope: .series)
|
||||||
|
|
||||||
|
#expect(wahl?.episode.id == "s1e1")
|
||||||
|
#expect(wahl?.resumes == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ist die Staffel gesehen, beginnt sie von vorn")
|
||||||
|
func watchedSeasonRestarts() {
|
||||||
|
let staffel = (1...3).map { folge(4, $0, gesehen: true) }
|
||||||
|
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s4e1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reihenfolge
|
||||||
|
|
||||||
|
/// Jellyfin sortiert `children(of:)` nach dem Namen, und das stellt
|
||||||
|
/// „Folge 10" vor „Folge 2". Sich darauf zu verlassen ginge genau so
|
||||||
|
/// lange gut, bis eine Staffel zehn Folgen hat.
|
||||||
|
@Test("Die Reihenfolge des Servers zählt nicht")
|
||||||
|
func ignoresServerOrder() {
|
||||||
|
let durcheinander = [folge(1, 10), folge(1, 2), folge(1, 1)]
|
||||||
|
#expect(EpisodeSelection.nextUp(in: durcheinander, scope: .season)?.episode.id == "s1e1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Lücken in der Nummerierung ändern nichts")
|
||||||
|
func gapsDoNotMatter() {
|
||||||
|
let staffel = [folge(1, 1, gesehen: true), folge(1, 7), folge(1, 9)]
|
||||||
|
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e7")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine fehlende Nummer ist keine Null, sondern „der Server hat nichts
|
||||||
|
/// gesagt". Solche Einträge gehören ans Ende.
|
||||||
|
@Test("Folgen ohne Nummer stehen hinten")
|
||||||
|
func unnumberedGoLast() {
|
||||||
|
let staffel = [folge(1, nil, id: "ohne"), folge(1, 1)]
|
||||||
|
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Zweimal dieselbe Eingabe ergibt zweimal dasselbe")
|
||||||
|
func isRepeatable() {
|
||||||
|
let staffel = [folge(1, nil, id: "b"), folge(1, nil, id: "a")]
|
||||||
|
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "a")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Specials
|
||||||
|
|
||||||
|
/// Staffel 0 steht vor Staffel 1 und ist trotzdem nicht der Anfang.
|
||||||
|
@Test("Specials werden bei der Serie übergangen")
|
||||||
|
func skipsSpecialsInSeries() {
|
||||||
|
let serie = [folge(0, 1), folge(1, 1)]
|
||||||
|
#expect(EpisodeSelection.nextUp(in: serie, scope: .series)?.episode.id == "s1e1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wer die Specials-Kachel angeklickt hat, meint die Specials.
|
||||||
|
@Test("Bei der Staffel bleiben sie stehen")
|
||||||
|
func keepsSpecialsInSeason() {
|
||||||
|
let specials = [folge(0, 1), folge(0, 2)]
|
||||||
|
#expect(EpisodeSelection.nextUp(in: specials, scope: .season)?.episode.id == "s0e1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gäbe es **nur** Specials, dürfte die Serie nicht wortlos nichts tun.
|
||||||
|
@Test("Eine Serie aus lauter Specials ergibt nichts")
|
||||||
|
func onlySpecialsIsNothing() {
|
||||||
|
#expect(EpisodeSelection.nextUp(in: [folge(0, 1)], scope: .series) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Was keine Folge ist
|
||||||
|
|
||||||
|
/// Jellyfin liefert in Listen nicht immer eine brauchbare Art. Sie
|
||||||
|
/// wegzuwerfen leerte die Liste — also bleibt `nil` stehen.
|
||||||
|
@Test("Behälter fliegen raus, Unbekanntes bleibt")
|
||||||
|
func dropsContainersKeepsUnknown() {
|
||||||
|
let gemischt = [
|
||||||
|
MediaItem(id: "staffel", kind: .season, title: "Staffel 1", index: 1),
|
||||||
|
MediaItem(id: "serie", kind: .show, title: "Serie"),
|
||||||
|
MediaItem(id: "unbekannt", kind: nil, title: "?", index: 1, parentIndex: 1),
|
||||||
|
]
|
||||||
|
let wahl = EpisodeSelection.nextUp(in: gemischt, scope: .series)
|
||||||
|
#expect(wahl?.episode.id == "unbekannt")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine leere Liste ergibt nichts")
|
||||||
|
func emptyIsNothing() {
|
||||||
|
#expect(EpisodeSelection.nextUp(in: [], scope: .series) == nil)
|
||||||
|
#expect(EpisodeSelection.nextUp(in: [], scope: .season) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user