Der Serienname ist jetzt der Weg zur Serie

Unter dem Folgentitel stand „S2 E1" als Text, und wer zur Serie wollte, musste
darunter eine Knopfreihe finden. Jetzt steht dort „Serienname · Staffel 2 ·
Episode 1", und Serienname wie Staffel führen dorthin, wohin sie zeigen. Der
Name steht ohnehin da — dann soll er auch der Weg sein.

`HeroBanner` nimmt dafür wahlweise eine fertige Zeile entgegen statt eines
Textes. Gebaut wird sie beim Aufrufer, weil nur der weiß, wohin ein Teil führt;
die Gestaltung kennt keine Ziele.

Die Knopfreihe bleibt bei Staffeln, wo sie der einzige Weg nach oben ist, und
entfällt bei Folgen.

Beim Entfernen eines Downloads gibt es keine Rückfrage mehr. Sie stand da, weil
„löschen" nach etwas Endgültigem klingt — es ist aber nur die heruntergeladene
Kopie, der Titel bleibt auf dem Server. Wer nach links wischt und auf „Löschen"
tippt, hat zweimal gesagt, was er will.

Und das Umschalten sieht jetzt nach Umzug aus: die Seitenleiste klappt nach
oben weg, die Leiste oben schiebt sich seitwärts herein — und zurück genauso.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 22:09:31 +02:00
co-authored by Claude Opus 5
parent 34593d9973
commit be4b961641
5 changed files with 133 additions and 32 deletions
+34
View File
@@ -5916,6 +5916,40 @@
}
}
}
},
"season.number": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Staffel %lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Season %lld"
}
}
}
},
"episode.number": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Episode %lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Episode %lld"
}
}
}
}
},
"version": "1.1"
+67 -9
View File
@@ -19,7 +19,12 @@ struct PlayableDetailView: View {
VStack(alignment: .leading, spacing: FlixySpacing.section) {
header
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
// unter dem Titel. Zwei Wege zum selben Ziel, einer davon als
// Knopfreihe am unteren Rand, waren einer zu viel.
if item.kind != .episode {
ParentLinks(item: item)
}
if item.hasMultipleVersions {
VersionPicker(media: item.versions, selection: $selectedVersion)
@@ -63,7 +68,8 @@ struct PlayableDetailView: View {
private var header: some View {
HeroBanner(
title: item.rowTitle,
metadata: metadataLine,
metadata: item.kind == .episode ? nil : metadataLine,
metadataContent: item.kind == .episode ? AnyView(episodeMetadata) : nil,
summary: nil,
artworkURL: session.backend?.artworkURL(
path: item.art ?? item.seriesArt ?? item.thumb,
@@ -104,22 +110,74 @@ struct PlayableDetailView: View {
}
}
/// Die Zeile unter dem Folgentitel — mit Wegen nach oben.
///
/// Serienname und „Staffel 2" führen dorthin, wohin sie zeigen. Vorher
/// stand hier „S2 E1" als Text und darunter eine Reihe Knöpfe; wer zur
/// Serie wollte, musste erst den Knopf finden. Der Name der Serie steht
/// ohnehin da — dann soll er auch der Weg sein.
@ViewBuilder
private var episodeMetadata: some View {
let plain = plainPieces
HStack(spacing: 0) {
if let series = item.seriesTitle, let showID = item.seriesID {
NavigationLink(value: MediaRoute(ratingKey: showID, title: series)) {
Text(series).underline()
}
.buttonStyle(.plain)
separator
} else if let series = item.seriesTitle {
Text(series)
separator
}
if let season = item.parentIndex {
let title = String(localized: "season.number \(season)")
if let seasonID = item.parentID {
NavigationLink(value: MediaRoute(ratingKey: seasonID, title: title)) {
Text(title).underline()
}
.buttonStyle(.plain)
} else {
Text(title)
}
separator
}
if let episode = item.index {
Text("episode.number \(episode)")
if !plain.isEmpty { separator }
}
Text(plain.joined(separator: " · "))
}
.lineLimit(1)
}
private var separator: some View {
Text(" · ")
}
/// Was in der Zeile nur dasteht und nirgendwohin führt.
private var plainPieces: [String] {
var pieces: [String] = []
if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) }
if let resolution = currentMedia?.videoResolution { pieces.append(resolution.uppercased()) }
return pieces
}
private var metadataLine: String? {
var pieces: [String] = []
if item.kind == .episode {
// Der Folgentitel steht jetzt groß darüber; ohne die Serie hier
// wüsste man auf dieser Seite nicht, wozu die Folge gehört.
if let series = item.seriesTitle { pieces.append(series) }
if let season = item.parentIndex, let episode = item.index {
pieces.append(String(localized: "episode.short \(season) \(episode)"))
}
}
if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) }
if let resolution = currentMedia?.videoResolution { pieces.append(resolution.uppercased()) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
}
/// Auswahl zwischen mehreren Fassungen desselben Titels.
+9 -19
View File
@@ -10,7 +10,6 @@ struct DownloadsView: View {
@Environment(NetworkConditions.self) private var network
@Environment(\.flixyAccent) private var accent
@State private var itemPendingRemoval: DownloadItem?
var body: some View {
Group {
@@ -27,22 +26,13 @@ struct DownloadsView: View {
.background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.downloads"))
.flixyInlineTitle()
.confirmationDialog(
Text("downloads.removeConfirm"),
isPresented: Binding(
get: { itemPendingRemoval != nil },
set: { if !$0 { itemPendingRemoval = nil } }
),
titleVisibility: .visible
) {
Button("action.delete", role: .destructive) {
if let item = itemPendingRemoval {
Task { await downloads.cancel(item) }
}
itemPendingRemoval = nil
}
Button("action.cancel", role: .cancel) { itemPendingRemoval = nil }
}
// Keine Rückfrage beim Entfernen.
//
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
// aber nur die heruntergeladene Kopie: der Titel bleibt auf dem Server
// und ist mit einem Tippen wieder da. Wer nach links wischt und auf
// „Löschen" tippt, hat zweimal gesagt, was er will — ein drittes Mal
// zu fragen ist keine Vorsicht, sondern Aufhalten.
}
/// Was die Übertragung gerade aufhält.
@@ -72,7 +62,7 @@ struct DownloadsView: View {
DownloadRow(
item: item,
hold: hold,
onRemove: { itemPendingRemoval = item }
onRemove: { Task { await downloads.cancel(item) } }
)
}
}
@@ -82,7 +72,7 @@ struct DownloadsView: View {
Section(group.title) {
ForEach(group.items) { item in
// Fertige Downloads wartet nichts mehr auf.
DownloadRow(item: item, hold: .none, onRemove: { itemPendingRemoval = item })
DownloadRow(item: item, hold: .none, onRemove: { Task { await downloads.cancel(item) } })
.contentShape(.rect)
.onTapGesture { play(item) }
}
+7 -2
View File
@@ -305,7 +305,12 @@ struct RootView: View {
placement: $placement.animation(Self.placementAnimation),
layout: navigationLayout
)
.transition(.move(edge: .leading).combined(with: .opacity))
// Sie klappt nach **oben** weg und kommt von oben
// zurück — und die Leiste oben schiebt sich seitwärts
// herein beziehungsweise hinaus. Zusammen sieht es aus,
// als zöge dieselbe Leiste um: erst zusammenklappen,
// dann hinüberschieben.
.transition(.move(edge: .top).combined(with: .opacity))
}
tabs
@@ -321,7 +326,7 @@ struct RootView: View {
selection: $selection,
placement: $placement.animation(Self.placementAnimation)
)
.transition(.move(edge: .top).combined(with: .opacity))
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
}
@@ -8,6 +8,14 @@ import SwiftUI
public struct HeroBanner<Actions: View>: View {
private let title: String
private let metadata: String?
/// Eine Zeile, in der einzelne Teile anklickbar sind.
///
/// Steht sie, tritt sie an die Stelle von `metadata`. Gebaut wird sie beim
/// Aufrufer, weil nur der weiß, wohin ein Teil führt — die Gestaltung
/// kennt keine Ziele. `AnyView` statt eines weiteren Platzhaltertyps: der
/// Aufmacher hat schon einen für die Knöpfe, und ein zweiter zöge sich
/// durch jeden Aufruf.
private let metadataContent: AnyView?
private let summary: String?
private let artworkURL: URL?
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
@@ -24,6 +32,7 @@ public struct HeroBanner<Actions: View>: View {
public init(
title: String,
metadata: String? = nil,
metadataContent: AnyView? = nil,
summary: String? = nil,
artworkURL: URL?,
logoURL: URL? = nil,
@@ -37,6 +46,7 @@ public struct HeroBanner<Actions: View>: View {
) {
self.title = title
self.metadata = metadata
self.metadataContent = metadataContent
self.summary = summary
self.artworkURL = artworkURL
self.logoURL = logoURL
@@ -141,7 +151,11 @@ public struct HeroBanner<Actions: View>: View {
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) {
titleView(scale: scale)
if let metadata {
if let metadataContent {
metadataContent
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)
} else if let metadata {
Text(metadata)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)