Eine Folge ist weder ein Film noch eine Serie
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f10fb4c1bc
commit
10a5070e58
@@ -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"
|
"version": "1.1"
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
/// Ob es überhaupt etwas zu starten gibt.
|
/// Ob es überhaupt etwas zu starten gibt.
|
||||||
///
|
///
|
||||||
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
/// 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) }
|
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,11 @@ struct PlayableDetailView: View {
|
|||||||
// Karussell auf dem Telefon quer ist.
|
// Karussell auf dem Telefon quer ist.
|
||||||
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
||||||
) {
|
) {
|
||||||
|
// 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 {
|
Button {
|
||||||
playback.play(item, mediaIndex: selectedVersion)
|
playback.play(item, mediaIndex: selectedVersion)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -128,6 +133,7 @@ struct PlayableDetailView: View {
|
|||||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Zeile unter dem Folgentitel — mit Wegen nach oben.
|
/// Die Zeile unter dem Folgentitel — mit Wegen nach oben.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -47,12 +47,18 @@ struct HeroSection: View {
|
|||||||
// Bildschirm leer.
|
// Bildschirm leer.
|
||||||
fillsAvailableSpace: sizeClass != .compact
|
fillsAvailableSpace: sizeClass != .compact
|
||||||
) {
|
) {
|
||||||
|
// Geplant heisst sichtbar, nicht abspielbar — statt des Knopfes
|
||||||
|
// der Termin.
|
||||||
|
if item.isUpcoming, let ab = item.availableFrom {
|
||||||
|
UpcomingNotice(date: ab)
|
||||||
|
} else {
|
||||||
Button {
|
Button {
|
||||||
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||||
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
// Aufmacher eine Serie — und Jellyfins „Zuletzt
|
||||||
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
// hinzugefügt" liefert neue Folgen als ihre Serie —,
|
||||||
// deren eigene Kennung an den Player, und der scheiterte mit
|
// schickte der direkte Weg deren eigene Kennung an den
|
||||||
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
// Player, und der scheiterte mit „keine abspielbare
|
||||||
|
// Datei". Eine Serie hat keine Fassungen.
|
||||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||||
} label: {
|
} label: {
|
||||||
Label(playLabel, systemImage: "play.fill")
|
Label(playLabel, systemImage: "play.fill")
|
||||||
@@ -62,6 +68,7 @@ struct HeroSection: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
|
}
|
||||||
|
|
||||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||||
Label("hero.details", systemImage: "info.circle")
|
Label("hero.details", systemImage: "info.circle")
|
||||||
|
|||||||
@@ -54,13 +54,12 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||||
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine
|
// **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der
|
||||||
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine
|
// eine fertige Startseite liefert, und das ist Absicht: welche Reihen
|
||||||
// App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
// eine App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
||||||
async let weiter = reihe(pfad: "/api/v1/home/continue")
|
async let weiter = reihe(pfad: "/api/v1/home/continue")
|
||||||
async let naechste = reihe(pfad: "/api/v1/home/next")
|
async let naechste = reihe(pfad: "/api/v1/home/next")
|
||||||
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self)
|
let mediatheken = (try? await libraries()) ?? []
|
||||||
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
|
|
||||||
|
|
||||||
var reihen: [MediaRowGroup] = []
|
var reihen: [MediaRowGroup] = []
|
||||||
|
|
||||||
@@ -76,18 +75,46 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
title: String(localized: "flaro.row.nextUp", bundle: .module),
|
title: String(localized: "flaro.row.nextUp", bundle: .module),
|
||||||
items: eintraege))
|
items: eintraege))
|
||||||
}
|
}
|
||||||
if let gefunden = try? await filme, !gefunden.items.isEmpty {
|
|
||||||
reihen.append(MediaRowGroup(
|
// **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.**
|
||||||
id: "flaro.movies",
|
//
|
||||||
title: String(localized: "flaro.row.movies", bundle: .module),
|
// Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem
|
||||||
items: gefunden.items.map { $0.neutral() }))
|
// 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<Wire.Series>.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<Wire.Movie>.self) else { continue }
|
||||||
|
titel = seite.items.map { $0.neutral() }
|
||||||
}
|
}
|
||||||
if let gefunden = try? await serien, !gefunden.items.isEmpty {
|
guard !titel.isEmpty else { continue }
|
||||||
reihen.append(MediaRowGroup(
|
reihen.append(MediaRowGroup(
|
||||||
id: "flaro.series",
|
id: "latest.\(mediathek.id)",
|
||||||
title: String(localized: "flaro.row.series", bundle: .module),
|
title: String(
|
||||||
items: gefunden.items.map { $0.neutral() }))
|
localized: "flaro.row.latestIn \(mediathek.title)",
|
||||||
|
bundle: .module),
|
||||||
|
libraryID: mediathek.id,
|
||||||
|
items: titel))
|
||||||
}
|
}
|
||||||
|
|
||||||
return reihen
|
return reihen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +162,21 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
|
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
|
||||||
return serie.neutral()
|
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
|
throw .notFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ enum Wire {
|
|||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
let files: [File]
|
let files: [File]
|
||||||
let state: ItemState?
|
let state: ItemState?
|
||||||
|
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
||||||
|
let publishAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Series: Decodable, Sendable {
|
struct Series: Decodable, Sendable {
|
||||||
@@ -101,6 +103,7 @@ enum Wire {
|
|||||||
let episodeCount: Int
|
let episodeCount: Int
|
||||||
let seasons: [Int]
|
let seasons: [Int]
|
||||||
let unwatchedCount: Int
|
let unwatchedCount: Int
|
||||||
|
let publishAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Episode: Decodable, Sendable {
|
struct Episode: Decodable, Sendable {
|
||||||
@@ -112,6 +115,10 @@ enum Wire {
|
|||||||
let stillImage: String?
|
let stillImage: String?
|
||||||
let files: [File]
|
let files: [File]
|
||||||
let state: ItemState?
|
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".
|
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||||
@@ -126,6 +133,12 @@ enum Wire {
|
|||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
let state: ItemState
|
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 {
|
struct CodeResponse: Decodable, Sendable {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ extension Wire.Movie {
|
|||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
|
availableFrom: publishAt,
|
||||||
versions: [Wire.fassung(id: id, files: files)]
|
versions: [Wire.fassung(id: id, files: files)]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ extension Wire.Series {
|
|||||||
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||||
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||||
// zu derselben Aussage zu haben.
|
// zu derselben Aussage zu haben.
|
||||||
|
availableFrom: publishAt,
|
||||||
episodeCount: episodeCount,
|
episodeCount: episodeCount,
|
||||||
playedEpisodeCount: episodeCount - unwatchedCount,
|
playedEpisodeCount: episodeCount - unwatchedCount,
|
||||||
childCount: seasons.count
|
childCount: seasons.count
|
||||||
@@ -60,11 +62,14 @@ extension Wire.Episode {
|
|||||||
index: episode,
|
index: episode,
|
||||||
parentIndex: season,
|
parentIndex: season,
|
||||||
seriesTitle: series?.title,
|
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,
|
playCount: state?.playCount,
|
||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
|
availableFrom: publishAt,
|
||||||
versions: [Wire.fassung(id: id, files: files)]
|
versions: [Wire.fassung(id: id, files: files)]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -85,15 +90,21 @@ extension Wire.Started {
|
|||||||
year: year,
|
year: year,
|
||||||
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
||||||
thumb: folge ? backdropImage : posterImage,
|
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,
|
seriesThumb: folge ? posterImage : nil,
|
||||||
index: episode,
|
index: episode,
|
||||||
parentIndex: season,
|
parentIndex: season,
|
||||||
seriesTitle: folge ? title : nil,
|
seriesTitle: folge ? title : nil,
|
||||||
|
seriesID: folge ? seriesId : nil,
|
||||||
playCount: state.playCount,
|
playCount: state.playCount,
|
||||||
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
||||||
lastPlayedAt: state.lastPlayedAt,
|
lastPlayedAt: state.lastPlayedAt,
|
||||||
isFavourite: state.favourite
|
isFavourite: state.favourite,
|
||||||
|
availableFrom: publishAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"flaro.row.movies": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
/// eine Fähigkeit, die es nur auf einem gibt.
|
/// eine Fähigkeit, die es nur auf einem gibt.
|
||||||
public let isFavourite: Bool
|
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.
|
// Zählwerte bei Serien und Staffeln.
|
||||||
public let episodeCount: Int?
|
public let episodeCount: Int?
|
||||||
public let playedEpisodeCount: Int?
|
public let playedEpisodeCount: Int?
|
||||||
@@ -117,6 +125,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
lastPlayedAt: Date? = nil,
|
lastPlayedAt: Date? = nil,
|
||||||
addedAt: Date? = nil,
|
addedAt: Date? = nil,
|
||||||
isFavourite: Bool = false,
|
isFavourite: Bool = false,
|
||||||
|
availableFrom: Date? = nil,
|
||||||
episodeCount: Int? = nil,
|
episodeCount: Int? = nil,
|
||||||
playedEpisodeCount: Int? = nil,
|
playedEpisodeCount: Int? = nil,
|
||||||
childCount: Int? = nil,
|
childCount: Int? = nil,
|
||||||
@@ -155,6 +164,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
self.lastPlayedAt = lastPlayedAt
|
self.lastPlayedAt = lastPlayedAt
|
||||||
self.addedAt = addedAt
|
self.addedAt = addedAt
|
||||||
self.isFavourite = isFavourite
|
self.isFavourite = isFavourite
|
||||||
|
self.availableFrom = availableFrom
|
||||||
self.episodeCount = episodeCount
|
self.episodeCount = episodeCount
|
||||||
self.playedEpisodeCount = playedEpisodeCount
|
self.playedEpisodeCount = playedEpisodeCount
|
||||||
self.childCount = childCount
|
self.childCount = childCount
|
||||||
@@ -225,6 +235,15 @@ extension MediaItem {
|
|||||||
|
|
||||||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
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.
|
/// Ab wo fortgesetzt wird.
|
||||||
public var resumeTime: Duration {
|
public var resumeTime: Duration {
|
||||||
.milliseconds(resumeMilliseconds ?? 0)
|
.milliseconds(resumeMilliseconds ?? 0)
|
||||||
|
|||||||
@@ -430,7 +430,18 @@ public sealed record EpisodeResponse(
|
|||||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||||
IReadOnlyList<MediaFileResponse> Files,
|
IReadOnlyList<MediaFileResponse> Files,
|
||||||
ItemStateResponse? State,
|
ItemStateResponse? State,
|
||||||
DateTimeOffset? PublishAt = null);
|
DateTimeOffset? PublishAt = null,
|
||||||
|
|
||||||
|
/// <summary>Zu welcher Serie sie gehört.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit eine Folge für sich geöffnet werden kann.</b> 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.
|
||||||
|
/// </remarks>
|
||||||
|
Guid? SeriesId = null);
|
||||||
|
|
||||||
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
||||||
|
|
||||||
@@ -631,7 +642,17 @@ public sealed record ContinueEntry(
|
|||||||
double? DurationSeconds = null,
|
double? DurationSeconds = null,
|
||||||
|
|
||||||
/// <summary>Ab wann abspielbar. In der Zukunft heisst: noch nicht.</summary>
|
/// <summary>Ab wann abspielbar. In der Zukunft heisst: noch nicht.</summary>
|
||||||
DateTimeOffset? PublishAt = null);
|
DateTimeOffset? PublishAt = null,
|
||||||
|
|
||||||
|
/// <summary>Das breite Bild der Serie, zu der die Folge gehört.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Neben dem Folgenbild, nicht statt seiner.</b> 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.
|
||||||
|
/// </remarks>
|
||||||
|
string? SeriesBackdropImage = null);
|
||||||
|
|
||||||
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
|
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
|
||||||
public sealed record SessionResponse(
|
public sealed record SessionResponse(
|
||||||
|
|||||||
@@ -666,6 +666,47 @@ public static class LibraryContentEndpoints
|
|||||||
PublishAt: folgen.Min(f => f.PublishAt)));
|
PublishAt: folgen.Min(f => f.PublishAt)));
|
||||||
}).RequireAuthorization();
|
}).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
|
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
||||||
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
|
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
|
||||||
// der Scan sie fand, nicht wann sie läuft.
|
// der Scan sie fand, nicht wann sie läuft.
|
||||||
@@ -709,7 +750,8 @@ public static class LibraryContentEndpoints
|
|||||||
naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
|
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(),
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
|
||||||
stände.GetValueOrDefault(naechste.Id),
|
stände.GetValueOrDefault(naechste.Id),
|
||||||
naechste.PublishAt));
|
naechste.PublishAt,
|
||||||
|
SeriesId: diese.SeriesId));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
||||||
@@ -747,7 +789,8 @@ public static class LibraryContentEndpoints
|
|||||||
dateien.Where(d => d.EpisodeId == f.Id)
|
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(),
|
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(f.Id),
|
stände.GetValueOrDefault(f.Id),
|
||||||
f.PublishAt)).ToList();
|
f.PublishAt,
|
||||||
|
SeriesId: id)).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ public static class PlaybackEndpoints
|
|||||||
|
|
||||||
var serien = await db.Series.AsNoTracking()
|
var serien = await db.Series.AsNoTracking()
|
||||||
.Where(x => serienIds.Contains(x.Id))
|
.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);
|
.ToDictionaryAsync(x => x.Id);
|
||||||
|
|
||||||
var liste = new List<ContinueEntry>();
|
var liste = new List<ContinueEntry>();
|
||||||
@@ -320,7 +320,8 @@ public static class PlaybackEndpoints
|
|||||||
serie.PosterImage, naechste.StillImage ?? serie.PosterImage,
|
serie.PosterImage, naechste.StillImage ?? serie.PosterImage,
|
||||||
zustand is null ? Leer() : Abbild(zustand),
|
zustand is null ? Leer() : Abbild(zustand),
|
||||||
SeriesId: x.SeriesId,
|
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
|
// 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,
|
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
|
||||||
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
|
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
|
||||||
|
SerienBild = s.BackdropImage,
|
||||||
})
|
})
|
||||||
.ToDictionaryAsync(f => f.Id);
|
.ToDictionaryAsync(f => f.Id);
|
||||||
|
|
||||||
@@ -467,7 +469,8 @@ public static class PlaybackEndpoints
|
|||||||
SeriesId: f.SerienId,
|
SeriesId: f.SerienId,
|
||||||
FileId: dateien.GetValueOrDefault(f.Id).Id,
|
FileId: dateien.GetValueOrDefault(f.Id).Id,
|
||||||
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
|
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
|
||||||
PublishAt: f.PublishAt));
|
PublishAt: f.PublishAt,
|
||||||
|
SeriesBackdropImage: f.SerienBild));
|
||||||
}
|
}
|
||||||
return liste;
|
return liste;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,44 @@ public class PlaybackTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine Folge muss sich für sich abrufen lassen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Die Apps reichen eine blanke Kennung durch.</b> 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.
|
||||||
|
/// </remarks>
|
||||||
|
[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<Page<SeriesResponse>>("/api/v1/series");
|
||||||
|
var serie = serien!.Items.Single();
|
||||||
|
var ausDerListe = (await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||||
|
$"/api/v1/series/{serie.Id}/episodes"))!.Items.Single();
|
||||||
|
|
||||||
|
var einzeln = await client.GetFromJsonAsync<EpisodeResponse>(
|
||||||
|
$"/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]
|
[Test]
|
||||||
public async Task Durchgeschautes_verlaesst_Weiterschauen()
|
public async Task Durchgeschautes_verlaesst_Weiterschauen()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ export type Angefangen = {
|
|||||||
durationSeconds?: number | null;
|
durationSeconds?: number | null;
|
||||||
/** Ab wann abspielbar. In der Zukunft heisst: noch nicht. */
|
/** Ab wann abspielbar. In der Zukunft heisst: noch nicht. */
|
||||||
publishAt?: string | null;
|
publishAt?: string | null;
|
||||||
|
/** Das breite Bild der Serie — für den Aufmacher, nicht für die Kachel. */
|
||||||
|
seriesBackdropImage?: string | null;
|
||||||
};
|
};
|
||||||
export type Bibliothek = {
|
export type Bibliothek = {
|
||||||
id: string; name: string;
|
id: string; name: string;
|
||||||
|
|||||||
+5
-1
@@ -268,7 +268,11 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag {
|
|||||||
id: folge ? eintrag.seriesId ?? eintrag.id : eintrag.id,
|
id: folge ? eintrag.seriesId ?? eintrag.id : eintrag.id,
|
||||||
titel: eintrag.title,
|
titel: eintrag.title,
|
||||||
schriftzug: null,
|
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,
|
jahr: eintrag.year,
|
||||||
laufzeit: null,
|
laufzeit: null,
|
||||||
freigabe: null,
|
freigabe: null,
|
||||||
|
|||||||
+15
-5
@@ -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-knoepfe .btn { height: 40px; padding: 0 var(--s-section); gap: 6px; }
|
||||||
|
|
||||||
.hero-punkte {
|
.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;
|
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 {
|
.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);
|
background: rgba(255, 255, 255, .28);
|
||||||
transition: background .2s ease;
|
transition: background .2s ease;
|
||||||
}
|
}
|
||||||
.hero-punkt.an { background: var(--text); }
|
.hero-punkt.an::before { background: var(--text); }
|
||||||
.hero-punkt:hover { background: rgba(255, 255, 255, .6); }
|
.hero-punkt:hover::before { background: rgba(255, 255, 255, .6); }
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.hero {
|
.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);
|
margin: calc(var(--s-loose) * -1) calc(var(--s-loose) * -1) var(--s-section);
|
||||||
}
|
}
|
||||||
.hero-inhalt { padding: var(--s-loose); max-width: none; }
|
.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ü ---------------------------------------------------------
|
/* --- Kontextmenü ---------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user