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>
130 lines
4.9 KiB
Swift
130 lines
4.9 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Eine waagerechte Reihe von Titeln.
|
|
struct MediaRow: View {
|
|
let row: MediaRowGroup
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
|
|
@Environment(HomeLayoutSettings.self) private var layout
|
|
|
|
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
|
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
|
private var landscapeWidth: CGFloat { 220 * layout.cardScale }
|
|
/// Schmaler, weil hochkant: bei gleicher Höhe passen so mehr Plakate
|
|
/// nebeneinander, und mehr Auswahl auf einen Blick ist genau der Zweck.
|
|
///
|
|
/// Beide mit demselben Faktor: ihr Verhältnis zueinander soll stimmen,
|
|
/// egal wie groß es insgesamt eingestellt ist.
|
|
private var posterWidth: CGFloat { 116 * layout.cardScale }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
Text(row.title)
|
|
.font(.flixyRowHeader)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
|
|
ScrollView(.horizontal) {
|
|
LazyHStack(spacing: FlixySpacing.regular) {
|
|
ForEach(row.items) { item in
|
|
QuickPlayTile(item: item) { card(for: item) }
|
|
}
|
|
}
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
|
|
// bleibt an beliebiger Stelle stehen.
|
|
.scrollTargetLayout()
|
|
}
|
|
.scrollTargetBehavior(.viewAligned)
|
|
.scrollIndicators(.never)
|
|
}
|
|
}
|
|
|
|
/// Szenenbild beim Weiterschauen, sonst das Plakat.
|
|
///
|
|
/// Angefangenes erkennt man an der Szene, an der man aufgehört hat —
|
|
/// dafür ist das Querformat richtig. Wer dagegen aussucht, was er als
|
|
/// Nächstes sehen will, sucht nach Plakaten.
|
|
@ViewBuilder
|
|
private func card(for item: MediaItem) -> some View {
|
|
if row.isContinueWatching {
|
|
LandscapeCard(
|
|
title: item.rowTitle,
|
|
subtitle: item.cardSubtitle,
|
|
artworkURL: session.backend?.artworkURL(
|
|
path: item.rowArtworkPath,
|
|
width: 440,
|
|
height: 248
|
|
),
|
|
progress: item.watchProgress
|
|
)
|
|
.frame(width: landscapeWidth)
|
|
} else {
|
|
PosterCard(
|
|
title: item.rowTitle,
|
|
subtitle: item.cardSubtitle,
|
|
artworkURL: session.backend?.artworkURL(
|
|
path: item.posterArtworkPath,
|
|
width: 300,
|
|
height: 450
|
|
),
|
|
progress: item.watchProgress
|
|
)
|
|
.frame(width: posterWidth)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MediaItem {
|
|
/// Der Titel auf einer Kachel in einer Reihe.
|
|
///
|
|
/// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei
|
|
/// einer Staffel der Name der Serie. Die Einordnung — Serie, Staffel —
|
|
/// gehört darunter. „Staffel 10" allein als Überschrift sagt nichts
|
|
/// darüber, welche Serie gemeint ist.
|
|
var rowTitle: String {
|
|
switch kind {
|
|
case .episode: title
|
|
// Die Serie steht je nach Antwort mal unter `parentTitle`, mal unter
|
|
// `seriesTitle`. Fehlt beides, bleibt „Staffel 2" — besser als
|
|
// gar keine Beschriftung.
|
|
case .season: parentTitle ?? seriesTitle ?? title
|
|
default: title
|
|
}
|
|
}
|
|
|
|
/// Welches Bild auf die Querformat-Kachel gehört.
|
|
///
|
|
/// Bei Folgen ist `thumb` das Szenenbild der Folge — genau richtig. Bei
|
|
/// Filmen ist `thumb` das Hochkant-Plakat, das im Querformat beschnitten
|
|
/// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl.
|
|
var rowArtworkPath: String? {
|
|
switch kind {
|
|
case .episode: thumb ?? seriesThumb
|
|
// Eine Staffel hat bei Jellyfin oft gar kein eigenes Bild — sie leiht
|
|
// sich das der Serie. Ohne diesen Rückfall blieb „Nachtstreife,
|
|
// Staffel 7" eine graue Fläche, obwohl die Serie ein Plakat hat.
|
|
case .season: art ?? thumb ?? seriesArt ?? seriesThumb
|
|
default: art ?? thumb
|
|
}
|
|
}
|
|
|
|
/// Welches Bild auf die Hochkant-Kachel gehört.
|
|
///
|
|
/// Bei Folgen das Plakat der Serie statt des Szenenbilds: ein 16:9-Bild
|
|
/// hochkant beschnitten zeigt einen zufälligen Ausschnitt und ist zum
|
|
/// Wiedererkennen wertlos.
|
|
var posterArtworkPath: String? {
|
|
switch kind {
|
|
case .episode: seriesThumb ?? parentThumb ?? thumb
|
|
// Das eigene Staffelplakat zuerst: es zeigt die Staffel. Gibt es
|
|
// keines, ist das der Serie allemal besser als nichts.
|
|
case .season: thumb ?? seriesThumb
|
|
default: thumb
|
|
}
|
|
}
|
|
}
|