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:
Scarriffle
2026-09-28 21:18:39 +02:00
co-authored by Claude Opus 5
parent 2a2afa8573
commit a1ff5cb7bb
12 changed files with 641 additions and 7 deletions
+2
View File
@@ -29,6 +29,7 @@ struct FlixyApp: App {
@State private var playback = PlaybackCoordinator()
@State private var playbackSettings = PlaybackSettings()
@State private var languages = LanguagePreferences()
@State private var quickPlay = QuickPlay()
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
/// läuft statt bei jedem Öffnen des Players neu zu starten.
@State private var network = NetworkConditions()
@@ -88,6 +89,7 @@ struct FlixyApp: App {
.environment(playback)
.environment(playbackSettings)
.environment(languages)
.environment(quickPlay)
.environment(network)
.environment(downloads)
.environment(homeLayout)
+68
View File
@@ -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": {
"comment": "Laufzeit mit Stunden",
"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"
+106
View File
@@ -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)
}
}
+7 -1
View File
@@ -8,6 +8,7 @@ struct HeroSection: View {
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
@Environment(\.flixyAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
@@ -30,7 +31,12 @@ struct HeroSection: View {
fillsAvailableSpace: sizeClass != .compact
) {
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(playLabel, systemImage: "play.fill")
.font(.flixyCardTitle)
+1 -4
View File
@@ -30,10 +30,7 @@ struct MediaRow: View {
ScrollView(.horizontal) {
LazyHStack(spacing: FlixySpacing.regular) {
ForEach(row.items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
card(for: item)
}
.buttonStyle(.plain)
QuickPlayTile(item: item) { card(for: item) }
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
+1 -2
View File
@@ -99,7 +99,7 @@ struct LibraryView: View {
spacing: FlixySpacing.loose
) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
QuickPlayTile(item: item) {
PosterCard(
title: item.title,
subtitle: item.cardSubtitle,
@@ -107,7 +107,6 @@ struct LibraryView: View {
progress: item.watchProgress
)
}
.buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) }
}
+14
View File
@@ -16,6 +16,7 @@ struct RootView: View {
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(LanguagePreferences.self) private var languages
@Environment(QuickPlay.self) private var quickPlay
@Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackDiagnostics.self) private var diagnostics
@@ -121,6 +122,19 @@ struct RootView: View {
// zuverlässig neu aus, ein Scene-Body nicht.
.flixyAccent(theme)
.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
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
// Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und