diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 9274535..8ddbbf7 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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" diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index 6217d86..3ac1d48 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -19,7 +19,12 @@ struct PlayableDetailView: View { VStack(alignment: .leading, spacing: FlixySpacing.section) { header - ParentLinks(item: item) + // 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. diff --git a/Flixy/Views/Downloads/DownloadsView.swift b/Flixy/Views/Downloads/DownloadsView.swift index efaffa0..bbe2489 100644 --- a/Flixy/Views/Downloads/DownloadsView.swift +++ b/Flixy/Views/Downloads/DownloadsView.swift @@ -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) } } diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 9ef11ee..e74355b 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -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)) } } } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 4b574f5..86feee9 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -8,6 +8,14 @@ import SwiftUI public struct HeroBanner: 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: 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: 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: 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)