From 10a5070e58e73d4d4b39e372770fdf493401bc1d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 6 Oct 2026 16:06:58 +0200 Subject: [PATCH] Eine Folge ist weder ein Film noch eine Serie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die App meldete auf der Startseite „Dieser Titel ist auf dem Server nicht mehr vorhanden" — über eine Folge, die sie selbst gerade anzeigte. Gemessen im Quelltext: `item(id:)` probiert `/movies/{id}`, dann `/series/{id}`, und wirft sonst `.notFound`. In „Weiterschauen" steht eine Folge mit ihrer eigenen Kennung, und einen Endpunkt für eine einzelne Folge gab es gar nicht. Jetzt gibt es `GET /api/v1/episodes/{id}` — mit `SeriesId`, denn ohne die Serie fehlen der Folge Titel und Bild. Im Simulator nachgestellt und nachgeprüft: die Seite steht. **Zuletzt hinzugefügt.** Die App zeigte „Filme" und „Serien" — den Anfang der Mediathek in der Reihenfolge der Datenbank, also immer denselben. Jetzt je Mediathek eine Reihe „Neu in …", sortiert nach Zugang. Die Kennungen beginnen nun mit `latest`, und daran hängen zwei Einstellungen, die an einem Flaro-Server deshalb nichts taten: „Gesehenes in Neuzugängen ausblenden" und „Aufmacher nur aus Neuzugängen". **Geplante Titel.** `MediaItem` kennt jetzt `availableFrom`. Aufmacher und Detailseite zeigen statt des Knopfes „Bald ist es soweit" mit dem Termin. Die Kachel spielte trotzdem los — im Simulator kam der Spieler bis „Wird vorbereitet …" —, also zählt ein geplanter Titel jetzt wie eine Sammlung: nichts zu starten, der Tipp führt auf die Seite. **Und das Bild im Aufmacher.** Bei einer angefangenen Folge stand dort ihr Standbild statt des Bildes der Serie; wer der Serie eines ausgesucht hatte, fand ein anderes vor. Die Kachel behält das Standbild — sie zeigt, wo man steht —, der Aufmacher bekommt `SeriesBackdropImage`. **Die Punkte im Karussell waren 26 × 3 Pixel.** Von drei Klicks auf denselben Punkt traf nur der auf der Mittellinie; fünf Pixel daneben geschah nichts. Der Knopf ist jetzt 27 Pixel hoch, sichtbar bleiben 3. Co-Authored-By: Claude Opus 5 --- Flaro/Resources/Localizable.xcstrings | 34 +++++++++ Flaro/Views/Components/QuickPlayTile.swift | 6 +- Flaro/Views/Components/UpcomingNotice.swift | 44 ++++++++++++ Flaro/Views/Detail/PlayableDetailView.swift | 56 ++++++++------- Flaro/Views/Home/HeroSection.swift | 35 +++++---- .../Sources/FlaroServerKit/FlaroBackend.swift | 72 +++++++++++++++---- .../FlaroServerKit/FlaroContracts.swift | 13 ++++ .../Sources/FlaroServerKit/FlaroMapping.swift | 17 ++++- .../FlaroServerKit/Localizable.xcstrings | 17 +++++ .../FlaroKit/Sources/MediaKit/MediaItem.swift | 19 +++++ server/src/Flaro.Api/Endpoints/Contracts.cs | 25 ++++++- .../Endpoints/LibraryContentEndpoints.cs | 47 +++++++++++- .../Flaro.Api/Endpoints/PlaybackEndpoints.cs | 9 ++- server/tests/Flaro.Api.Tests/PlaybackTests.cs | 38 ++++++++++ web/app/src/api.ts | 2 + web/app/src/hero.ts | 6 +- web/app/src/style.css | 20 ++++-- 17 files changed, 389 insertions(+), 71 deletions(-) create mode 100644 Flaro/Views/Components/UpcomingNotice.swift diff --git a/Flaro/Resources/Localizable.xcstrings b/Flaro/Resources/Localizable.xcstrings index 99e4210..dd6d12f 100644 --- a/Flaro/Resources/Localizable.xcstrings +++ b/Flaro/Resources/Localizable.xcstrings @@ -7837,6 +7837,40 @@ } } } + }, + "upcoming.from %@": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ab %@ Uhr" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "From %@" + } + } + } + }, + "upcoming.title": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bald ist es soweit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Coming soon" + } + } + } } }, "version": "1.1" diff --git a/Flaro/Views/Components/QuickPlayTile.swift b/Flaro/Views/Components/QuickPlayTile.swift index 3f6e6c1..29d287e 100644 --- a/Flaro/Views/Components/QuickPlayTile.swift +++ b/Flaro/Views/Components/QuickPlayTile.swift @@ -57,7 +57,11 @@ struct QuickPlayTile: View { /// 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 } + /// Ein geplanter Titel ebenso: der Server lehnt ihn bis zum Termin ab, + /// und ein Tipp auf die Kachel startete trotzdem — gemessen am Simulator, + /// der Spieler kam bis „Wird vorbereitet …". Dort führt der Tipp jetzt + /// auf die Seite, wo der Termin steht. + private var isPlayable: Bool { item.kind != .collection && !item.isUpcoming } private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) } diff --git a/Flaro/Views/Components/UpcomingNotice.swift b/Flaro/Views/Components/UpcomingNotice.swift new file mode 100644 index 0000000..09f9a04 --- /dev/null +++ b/Flaro/Views/Components/UpcomingNotice.swift @@ -0,0 +1,44 @@ +import SwiftUI +import FlaroDesign +import MediaKit + +/// Steht dort, wo sonst „Abspielen" stünde. +/// +/// **Ein Knopf, der nichts tut, ist schlimmer als keiner.** Ein geplanter +/// Titel steht in der Mediathek, damit man weiss, dass er kommt — das ist der +/// Sinn des Planens. Der Server lehnt ihn bis zum Termin ab; stünde der Knopf +/// trotzdem da, wäre die Reihenfolge: draufdrücken, freuen, Absage lesen. +/// +/// Ein Baustein für Aufmacher und Detailseite, weil sonst an der einen Stelle +/// der Termin stünde und an der anderen weiter der Knopf. +struct UpcomingNotice: View { + @Environment(\.flaroAccent) private var accent + + let date: Date + + var body: some View { + HStack(spacing: FlaroSpacing.regular) { + Image(systemName: "clock") + .font(.flaroCardTitle) + .foregroundStyle(accent) + + VStack(alignment: .leading, spacing: 2) { + Text("upcoming.title") + .font(.flaroCardTitle) + Text("upcoming.from \(Self.formatted(date))") + .font(.flaroCaption) + .foregroundStyle(Color.flaroTextSecondary) + } + } + .padding(.horizontal, FlaroSpacing.loose) + .padding(.vertical, FlaroSpacing.snug) + .background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control)) + .accessibilityElement(children: .combine) + } + + /// „Dienstag, 6. Oktober 2026 um 18:00" — in der Sprache des Geräts. + static func formatted(_ date: Date) -> String { + date.formatted( + .dateTime.weekday(.wide).day().month(.wide).year().hour().minute()) + } +} diff --git a/Flaro/Views/Detail/PlayableDetailView.swift b/Flaro/Views/Detail/PlayableDetailView.swift index 539c35a..712872c 100644 --- a/Flaro/Views/Detail/PlayableDetailView.swift +++ b/Flaro/Views/Detail/PlayableDetailView.swift @@ -97,35 +97,41 @@ struct PlayableDetailView: View { // Karussell auf dem Telefon quer ist. aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact) ) { - Button { - playback.play(item, mediaIndex: selectedVersion) - } label: { - Label( - item.watchProgress == nil ? "hero.play" : "hero.resume", - systemImage: "play.fill" - ) - .font(.flaroCardTitle) - .padding(.horizontal, FlaroSpacing.loose) - .padding(.vertical, FlaroSpacing.snug) - } - .buttonStyle(.borderedProminent) - .tint(accent) - .disabled(currentMedia?.files.first == nil) - - if let progress = item.watchProgress, progress > 0 { + // Geplant heisst sichtbar, nicht abspielbar. Weder Abspielen + // noch Herunterladen — der Server lehnt beides bis zum Termin ab. + if item.isUpcoming, let ab = item.availableFrom { + UpcomingNotice(date: ab) + } else { Button { - playback.play(item, mediaIndex: selectedVersion, resuming: false) + playback.play(item, mediaIndex: selectedVersion) } label: { - Label("detail.playFromStart", systemImage: "gobackward") - .labelStyle(.iconOnly) - .font(.flaroCardTitle) - .padding(FlaroSpacing.snug) + Label( + item.watchProgress == nil ? "hero.play" : "hero.resume", + systemImage: "play.fill" + ) + .font(.flaroCardTitle) + .padding(.horizontal, FlaroSpacing.loose) + .padding(.vertical, FlaroSpacing.snug) } - .buttonStyle(.bordered) - .tint(Color.flaroTextSecondary) - } + .buttonStyle(.borderedProminent) + .tint(accent) + .disabled(currentMedia?.files.first == nil) - DownloadButton(item: item, mediaIndex: selectedVersion) + if let progress = item.watchProgress, progress > 0 { + Button { + playback.play(item, mediaIndex: selectedVersion, resuming: false) + } label: { + Label("detail.playFromStart", systemImage: "gobackward") + .labelStyle(.iconOnly) + .font(.flaroCardTitle) + .padding(FlaroSpacing.snug) + } + .buttonStyle(.bordered) + .tint(Color.flaroTextSecondary) + } + + DownloadButton(item: item, mediaIndex: selectedVersion) + } } } diff --git a/Flaro/Views/Home/HeroSection.swift b/Flaro/Views/Home/HeroSection.swift index f6c7732..bb3f49d 100644 --- a/Flaro/Views/Home/HeroSection.swift +++ b/Flaro/Views/Home/HeroSection.swift @@ -47,21 +47,28 @@ struct HeroSection: View { // Bildschirm leer. fillsAvailableSpace: sizeClass != .compact ) { - Button { - // **Ü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(.flaroCardTitle) - .padding(.horizontal, FlaroSpacing.loose) - .padding(.vertical, FlaroSpacing.snug) + // Geplant heisst sichtbar, nicht abspielbar — statt des Knopfes + // der Termin. + if item.isUpcoming, let ab = item.availableFrom { + UpcomingNotice(date: ab) + } else { + Button { + // **Ü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(.flaroCardTitle) + .padding(.horizontal, FlaroSpacing.loose) + .padding(.vertical, FlaroSpacing.snug) + } + .buttonStyle(.borderedProminent) + .tint(accent) } - .buttonStyle(.borderedProminent) - .tint(accent) NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { Label("hero.details", systemImage: "info.circle") diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift index 32bd765..33d278d 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift @@ -54,13 +54,12 @@ public actor FlaroBackend: MediaBackend { } public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] { - // **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine - // fertige Startseite liefert, und das ist Absicht: welche Reihen eine - // App zeigt, entscheidet die App. Der Server liefert die Zutaten. + // **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der + // eine fertige Startseite liefert, und das ist Absicht: welche Reihen + // eine App zeigt, entscheidet die App. Der Server liefert die Zutaten. async let weiter = reihe(pfad: "/api/v1/home/continue") async let naechste = reihe(pfad: "/api/v1/home/next") - async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self) - async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self) + let mediatheken = (try? await libraries()) ?? [] var reihen: [MediaRowGroup] = [] @@ -76,18 +75,46 @@ public actor FlaroBackend: MediaBackend { title: String(localized: "flaro.row.nextUp", bundle: .module), items: eintraege)) } - if let gefunden = try? await filme, !gefunden.items.isEmpty { + + // **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.** + // + // Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem + // Anfang der Mediathek in der Reihenfolge der Datenbank — also immer + // demselben. Drei Dinge waren damit falsch: es war kein Neuzugang, + // es warf getrennte Mediatheken wieder zusammen, und die Kennungen + // begannen nicht mit `latest`. An dieser Vorsilbe hängen zwei + // Einstellungen — „Gesehenes in Neuzugängen ausblenden" und + // „Aufmacher nur aus Neuzugängen" —, und beide taten an einem + // Flaro-Server deshalb nichts. + for mediathek in mediatheken { + let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies" + let abfrage = [ + URLQueryItem(name: "library", value: mediathek.id), + URLQueryItem(name: "sort", value: "added"), + URLQueryItem(name: "take", value: "24"), + ] + let titel: [MediaItem] + if mediathek.kind == .show { + guard let seite = try? await client.send( + FlaroRequest(path: pfad, query: abfrage), + as: Wire.Page.self) else { continue } + titel = seite.items.map { $0.neutral() } + } else { + guard let seite = try? await client.send( + FlaroRequest(path: pfad, query: abfrage), + as: Wire.Page.self) else { continue } + titel = seite.items.map { $0.neutral() } + } + guard !titel.isEmpty else { continue } reihen.append(MediaRowGroup( - id: "flaro.movies", - title: String(localized: "flaro.row.movies", bundle: .module), - items: gefunden.items.map { $0.neutral() })) - } - if let gefunden = try? await serien, !gefunden.items.isEmpty { - reihen.append(MediaRowGroup( - id: "flaro.series", - title: String(localized: "flaro.row.series", bundle: .module), - items: gefunden.items.map { $0.neutral() })) + id: "latest.\(mediathek.id)", + title: String( + localized: "flaro.row.latestIn \(mediathek.title)", + bundle: .module), + libraryID: mediathek.id, + items: titel)) } + return reihen } @@ -135,6 +162,21 @@ public actor FlaroBackend: MediaBackend { FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) { return serie.neutral() } + // **Und zuletzt eine Folge.** + // + // Sie fehlte, und das war der Grund für „Dieser Titel ist auf dem + // Server nicht mehr vorhanden" direkt von der Startseite: in + // „Weiterschauen" steht eine Folge mit ihrer eigenen Kennung, und die + // ist weder ein Film noch eine Serie. + if let folge = try? await client.send( + FlaroRequest(path: "/api/v1/episodes/\(id)"), as: Wire.Episode.self) { + var serie: Wire.Series? + if let kennung = folge.seriesId { + serie = try? await client.send( + FlaroRequest(path: "/api/v1/series/\(kennung)"), as: Wire.Series.self) + } + return folge.neutral(series: serie) + } throw .notFound } diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift index e20d032..d2940bd 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift @@ -89,6 +89,8 @@ enum Wire { let backdropImage: String? let files: [File] let state: ItemState? + /// Ab wann abspielbar. In der Zukunft heisst: noch nicht. + let publishAt: Date? } struct Series: Decodable, Sendable { @@ -101,6 +103,7 @@ enum Wire { let episodeCount: Int let seasons: [Int] let unwatchedCount: Int + let publishAt: Date? } struct Episode: Decodable, Sendable { @@ -112,6 +115,10 @@ enum Wire { let stillImage: String? let files: [File] let state: ItemState? + let publishAt: Date? + /// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich + /// nicht darstellbar: Titel und Bild der Serie fehlten. + let seriesId: String? } /// Eine Kachel aus „Weiterschauen" oder „Nächste Folge". @@ -126,6 +133,12 @@ enum Wire { let posterImage: String? let backdropImage: String? let state: ItemState + /// Zu welcher Serie die Folge gehört. + let seriesId: String? + /// Das breite Bild der Serie — für den Aufmacher, nicht für die + /// Kachel: die will das Standbild der Folge. + let seriesBackdropImage: String? + let publishAt: Date? } struct CodeResponse: Decodable, Sendable { diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift index 3289d9b..4d79bf9 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift @@ -20,6 +20,7 @@ extension Wire.Movie { resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) }, lastPlayedAt: state?.lastPlayedAt, isFavourite: state?.favourite ?? false, + availableFrom: publishAt, versions: [Wire.fassung(id: id, files: files)] ) } @@ -39,6 +40,7 @@ extension Wire.Series { // offen ist — die App zeigt daraus die Plakette, rechnet aber // intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege // zu derselben Aussage zu haben. + availableFrom: publishAt, episodeCount: episodeCount, playedEpisodeCount: episodeCount - unwatchedCount, childCount: seasons.count @@ -60,11 +62,14 @@ extension Wire.Episode { index: episode, parentIndex: season, seriesTitle: series?.title, - seriesID: series?.id, + // Die Serie aus dem Vertrag, wenn sie nicht mitgegeben wurde: + // ohne sie führte der Weg zurück zur Serie ins Leere. + seriesID: series?.id ?? seriesId, playCount: state?.playCount, resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) }, lastPlayedAt: state?.lastPlayedAt, isFavourite: state?.favourite ?? false, + availableFrom: publishAt, versions: [Wire.fassung(id: id, files: files)] ) } @@ -85,15 +90,21 @@ extension Wire.Started { year: year, duration: state.runtimeSeconds.map { Int($0 * 1000) }, thumb: folge ? backdropImage : posterImage, - art: backdropImage, + // **Für die Fläche über den halben Bildschirm das Bild der + // Serie.** `backdropImage` ist bei einer Folge ihr Standbild — + // richtig für die Kachel, falsch für den Aufmacher: wer der + // Serie ein Bild ausgesucht hatte, fand dort ein anderes vor. + art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage, seriesThumb: folge ? posterImage : nil, index: episode, parentIndex: season, seriesTitle: folge ? title : nil, + seriesID: folge ? seriesId : nil, playCount: state.playCount, resumeMilliseconds: Int(state.positionSeconds * 1000), lastPlayedAt: state.lastPlayedAt, - isFavourite: state.favourite + isFavourite: state.favourite, + availableFrom: publishAt ) } } diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/Localizable.xcstrings b/Packages/FlaroKit/Sources/FlaroServerKit/Localizable.xcstrings index 1e275e2..a5c85c9 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/Localizable.xcstrings +++ b/Packages/FlaroKit/Sources/FlaroServerKit/Localizable.xcstrings @@ -35,6 +35,23 @@ } } }, + "flaro.row.latestIn %@": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu in %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "New in %@" + } + } + } + }, "flaro.row.movies": { "extractionState": "manual", "localizations": { diff --git a/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift b/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift index a176a5e..b7760e4 100644 --- a/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift +++ b/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift @@ -71,6 +71,14 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable { /// eine Fähigkeit, die es nur auf einem gibt. public let isFavourite: Bool + /// Ab wann es den Titel gibt — `nil` heisst: jetzt. + /// + /// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das + /// ist der Sinn des Planens: man soll sehen, dass er kommt. Ohne dieses + /// Feld bot die App „Abspielen" an und bekam vom Server eine Absage — + /// ein Knopf, der ein Versprechen gibt und es zurücknimmt. + public let availableFrom: Date? + // Zählwerte bei Serien und Staffeln. public let episodeCount: Int? public let playedEpisodeCount: Int? @@ -117,6 +125,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable { lastPlayedAt: Date? = nil, addedAt: Date? = nil, isFavourite: Bool = false, + availableFrom: Date? = nil, episodeCount: Int? = nil, playedEpisodeCount: Int? = nil, childCount: Int? = nil, @@ -155,6 +164,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable { self.lastPlayedAt = lastPlayedAt self.addedAt = addedAt self.isFavourite = isFavourite + self.availableFrom = availableFrom self.episodeCount = episodeCount self.playedEpisodeCount = playedEpisodeCount self.childCount = childCount @@ -225,6 +235,15 @@ extension MediaItem { public var isWatched: Bool { (playCount ?? 0) > 0 } + /// Ob der Titel erst noch kommt. + /// + /// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an + /// einem Bildschirm „Abspielen" und am nächsten der Termin. + public var isUpcoming: Bool { + guard let availableFrom else { return false } + return availableFrom > Date() + } + /// Ab wo fortgesetzt wird. public var resumeTime: Duration { .milliseconds(resumeMilliseconds ?? 0) diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index ec2875f..dfa3cb3 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -430,7 +430,18 @@ public sealed record EpisodeResponse( Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, IReadOnlyList Files, ItemStateResponse? State, - DateTimeOffset? PublishAt = null); + DateTimeOffset? PublishAt = null, + + /// Zu welcher Serie sie gehört. + /// + /// + /// Damit eine Folge für sich geöffnet werden kann. Die Apps + /// reichen eine Kennung durch, ohne zu wissen, was dahintersteckt. War es + /// eine Folge, fand sich weder ein Film noch eine Serie, und die + /// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr + /// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte. + /// + Guid? SeriesId = null); public sealed record Page(IReadOnlyList Items, int Total); @@ -631,7 +642,17 @@ public sealed record ContinueEntry( double? DurationSeconds = null, /// Ab wann abspielbar. In der Zukunft heisst: noch nicht. - DateTimeOffset? PublishAt = null); + DateTimeOffset? PublishAt = null, + + /// Das breite Bild der Serie, zu der die Folge gehört. + /// + /// + /// Neben dem Folgenbild, nicht statt seiner. Die Kachel will das + /// Bild der Folge — es zeigt, wo man steht. Der Aufmacher füllt die halbe + /// Seite und will das Bild der Serie; dort stand bisher das Folgenbild, + /// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor. + /// + string? SeriesBackdropImage = null); /// Eine laufende Wiedergabe — für das Dashboard. public sealed record SessionResponse( diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index a3ea99f..10dc95a 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -666,6 +666,47 @@ public static class LibraryContentEndpoints PublishAt: folgen.Min(f => f.PublishAt))); }).RequireAuthorization(); + // **Eine Folge für sich.** + // + // Es gab sie nur im Rudel — als Liste einer Serie. Die Apps reichen + // aber eine blanke Kennung durch: sie haben sie aus „Weiterschauen" + // und wissen nicht, ob ein Film, eine Serie oder eine Folge + // dahintersteckt. Sie fragten der Reihe nach Film und Serie, fanden + // beides nicht und meldeten „Dieser Titel ist auf dem Server nicht + // mehr vorhanden" — über eine Folge, die sie selbst gerade zeigten. + app.MapGet("/api/v1/episodes/{id:guid}", async ( + Guid id, ClaimsPrincipal wer, FlaroContext db) => + { + var folge = await db.Episodes.AsNoTracking() + .Where(f => f.Id == id) + .Select(f => new + { + f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, + f.Title, f.Overview, f.StillImage, f.PublishAt, + }) + .FirstOrDefaultAsync(); + if (folge is null) return Results.NotFound(); + + var dateien = await db.MediaFiles.AsNoTracking() + .Where(d => d.EpisodeId == id) + .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel }) + .ToListAsync(); + var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); + var stände = await ZuständeZu(db, wer, folgen: [id]); + + var wurzel = await MediathekspfadFuer(db, wer, + await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId) + .Select(x => x.LibraryId).FirstAsync()); + + return Results.Ok(new EpisodeResponse( + folge.Id, folge.SeasonNumber, folge.EpisodeNumber, + folge.Title, folge.Overview, folge.StillImage, + dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(), + stände.GetValueOrDefault(id), + folge.PublishAt, + SeriesId: folge.SeriesId)); + }).RequireAuthorization(); + // **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann // Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann // der Scan sie fand, nicht wann sie läuft. @@ -709,7 +750,8 @@ public static class LibraryContentEndpoints naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild, dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(), stände.GetValueOrDefault(naechste.Id), - naechste.PublishAt)); + naechste.PublishAt, + SeriesId: diese.SeriesId)); }).RequireAuthorization(); app.MapGet("/api/v1/series/{id:guid}/episodes", async ( @@ -747,7 +789,8 @@ public static class LibraryContentEndpoints dateien.Where(d => d.EpisodeId == f.Id) .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(), stände.GetValueOrDefault(f.Id), - f.PublishAt)).ToList(); + f.PublishAt, + SeriesId: id)).ToList(); return Results.Ok(new Page(items, items.Count)); }).RequireAuthorization(); diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 04ef834..3caa54f 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -296,7 +296,7 @@ public static class PlaybackEndpoints var serien = await db.Series.AsNoTracking() .Where(x => serienIds.Contains(x.Id)) - .Select(x => new { x.Id, x.Title, x.PosterImage }) + .Select(x => new { x.Id, x.Title, x.PosterImage, x.BackdropImage }) .ToDictionaryAsync(x => x.Id); var liste = new List(); @@ -320,7 +320,8 @@ public static class PlaybackEndpoints serie.PosterImage, naechste.StillImage ?? serie.PosterImage, zustand is null ? Leer() : Abbild(zustand), SeriesId: x.SeriesId, - PublishAt: naechste.PublishAt)); + PublishAt: naechste.PublishAt, + SeriesBackdropImage: serie.BackdropImage)); } // Die Dateien erst jetzt, für die ausgewählten Folgen — nicht für @@ -445,6 +446,7 @@ public static class PlaybackEndpoints { f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage, SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt, + SerienBild = s.BackdropImage, }) .ToDictionaryAsync(f => f.Id); @@ -467,7 +469,8 @@ public static class PlaybackEndpoints SeriesId: f.SerienId, FileId: dateien.GetValueOrDefault(f.Id).Id, DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden, - PublishAt: f.PublishAt)); + PublishAt: f.PublishAt, + SeriesBackdropImage: f.SerienBild)); } return liste; } diff --git a/server/tests/Flaro.Api.Tests/PlaybackTests.cs b/server/tests/Flaro.Api.Tests/PlaybackTests.cs index f8e8b6b..be628f9 100644 --- a/server/tests/Flaro.Api.Tests/PlaybackTests.cs +++ b/server/tests/Flaro.Api.Tests/PlaybackTests.cs @@ -179,6 +179,44 @@ public class PlaybackTests }); } + /// Eine Folge muss sich für sich abrufen lassen. + /// + /// + /// Die Apps reichen eine blanke Kennung durch. Sie haben sie aus + /// „Weiterschauen" und wissen nicht, was dahintersteckt — also fragen sie + /// der Reihe nach. Fand sich weder Film noch Serie, meldeten sie „Dieser + /// Titel ist auf dem Server nicht mehr vorhanden", über eine Folge, die + /// sie selbst gerade anzeigten. + /// + [Test] + public async Task Eine_Folge_laesst_sich_einzeln_abrufen() + { + Datei("Dark (2017)/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items.Single(); + var ausDerListe = (await client.GetFromJsonAsync>( + $"/api/v1/series/{serie.Id}/episodes"))!.Items.Single(); + + var einzeln = await client.GetFromJsonAsync( + $"/api/v1/episodes/{ausDerListe.Id}"); + + Assert.Multiple(() => + { + Assert.That(einzeln!.Id, Is.EqualTo(ausDerListe.Id)); + Assert.That(einzeln.Season, Is.EqualTo(ausDerListe.Season)); + Assert.That(einzeln.Episode, Is.EqualTo(ausDerListe.Episode)); + Assert.That(einzeln.SeriesId, Is.EqualTo(serie.Id), + "ohne die Serie fehlen der Folge Titel und Bild"); + Assert.That(einzeln.Files, Has.Count.EqualTo(ausDerListe.Files.Count)); + }); + + var unbekannt = await client.GetAsync($"/api/v1/episodes/{Guid.NewGuid()}"); + Assert.That(unbekannt.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + [Test] public async Task Durchgeschautes_verlaesst_Weiterschauen() { diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 10492e2..24efd95 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -107,6 +107,8 @@ export type Angefangen = { durationSeconds?: number | null; /** Ab wann abspielbar. In der Zukunft heisst: noch nicht. */ publishAt?: string | null; + /** Das breite Bild der Serie — für den Aufmacher, nicht für die Kachel. */ + seriesBackdropImage?: string | null; }; export type Bibliothek = { id: string; name: string; diff --git a/web/app/src/hero.ts b/web/app/src/hero.ts index 8d9e07e..ebf2391 100644 --- a/web/app/src/hero.ts +++ b/web/app/src/hero.ts @@ -268,7 +268,11 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag { id: folge ? eintrag.seriesId ?? eintrag.id : eintrag.id, titel: eintrag.title, schriftzug: null, - hintergrund: eintrag.backdropImage ?? '', + // **Das Bild der Serie, nicht das der Folge.** `backdropImage` ist bei + // einer Folge ihr eigenes Standbild — richtig für die Kachel, falsch für + // eine Fläche über den halben Bildschirm: wer der Serie ein Bild + // ausgesucht hat, fand hier ein anderes vor. + hintergrund: eintrag.seriesBackdropImage ?? eintrag.backdropImage ?? '', jahr: eintrag.year, laufzeit: null, freigabe: null, diff --git a/web/app/src/style.css b/web/app/src/style.css index c9dd33e..87a0079 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1147,16 +1147,26 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; } .hero-knoepfe .btn { height: 40px; padding: 0 var(--s-section); gap: 6px; } .hero-punkte { - position: absolute; right: var(--s-page); bottom: var(--s-page); + position: absolute; right: var(--s-page); bottom: calc(var(--s-page) - 12px); display: flex; gap: 6px; } + +/* **Der Strich ist drei Pixel hoch, die Fläche darf es nicht sein.** + Gemessen: von drei Klicks auf denselben Punkt traf nur der genau auf der + Mittellinie; fünf Pixel darüber oder darunter geschah nichts. Der Knopf + trägt deshalb jetzt 27 Pixel Höhe, von denen man 3 sieht. */ .hero-punkt { - width: 26px; height: 3px; padding: 0; border: 0; border-radius: 2px; + width: 26px; height: 27px; padding: 0; border: 0; + background: none; + display: flex; align-items: center; +} +.hero-punkt::before { + content: ''; flex: 1; height: 3px; border-radius: 2px; background: rgba(255, 255, 255, .28); transition: background .2s ease; } -.hero-punkt.an { background: var(--text); } -.hero-punkt:hover { background: rgba(255, 255, 255, .6); } +.hero-punkt.an::before { background: var(--text); } +.hero-punkt:hover::before { background: rgba(255, 255, 255, .6); } @media (max-width: 860px) { .hero { @@ -1164,7 +1174,7 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; } margin: calc(var(--s-loose) * -1) calc(var(--s-loose) * -1) var(--s-section); } .hero-inhalt { padding: var(--s-loose); max-width: none; } - .hero-punkte { right: var(--s-loose); bottom: var(--s-loose); } + .hero-punkte { right: var(--s-loose); bottom: calc(var(--s-loose) - 12px); } } /* --- Kontextmenü ---------------------------------------------------------