Navigation: Glas, oben oder seitlich, und mehr Reihen auf der Startseite
Drei Dinge aus dem letzten Durchgang. **Die Startseite hatte zu wenig anzubieten.** Es gab eine Reihe „Zuletzt hinzugefügt" für alles zusammen. Jetzt gibt es eine je Mediathek — „Neu in Filme" und „Neu in Serien" sind zwei verschiedene Fragen, und wer nach einem Film sucht, blättert in einer gemeinsamen Reihe an Folgen vorbei. Dazu Favoriten, die es bei Jellyfin ohnehin gibt. Die drei Abfragen laufen nebeneinander; nur die Mediatheken werden streng geholt, weil sie die Probe darauf sind, ob der Server überhaupt antwortet. **Die Seitenleiste ist jetzt echtes Glas**, nicht das alte Material: im ausfahrbaren Blatt über `glassEffect`, in der geteilten Ansicht auf dem iPad vom System selbst — dort lag mein eigener Hintergrund darüber und machte ihn stumpf. Die zum Inhalt zeigende Kante ist gerundet, damit das Blatt sichtbar über der Seite liegt statt daneben zu stehen. **Die Navigation kann nach oben.** Eine Einstellung unter Erscheinungsbild, seitlich oder oben. Oben stehen alle Einträge gleichzeitig da, als Glaskapseln in einer waagerecht scrollenden Reihe — die Zahl der Mediatheken kommt vom Server, und zusammengequetschte Beschriftungen wären die schlechtere Antwort als eine Reihe, die weiterläuft. Angesehen auf iPhone und iPad: beide Formen, das Glas in beiden Leisten, die neuen Reihen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5763d866bf
commit
5cebfed36b
@@ -25,6 +25,7 @@ struct FlixyApp: App {
|
|||||||
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
@State private var homeLayout = HomeLayoutSettings()
|
@State private var homeLayout = HomeLayoutSettings()
|
||||||
|
@State private var navigation = NavigationSettings()
|
||||||
@State private var diagnostics = PlaybackDiagnostics()
|
@State private var diagnostics = PlaybackDiagnostics()
|
||||||
@State private var downloads: DownloadCoordinator
|
@State private var downloads: DownloadCoordinator
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ struct FlixyApp: App {
|
|||||||
.environment(network)
|
.environment(network)
|
||||||
.environment(downloads)
|
.environment(downloads)
|
||||||
.environment(homeLayout)
|
.environment(homeLayout)
|
||||||
|
.environment(navigation)
|
||||||
.environment(diagnostics)
|
.environment(diagnostics)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
||||||
|
|||||||
+3210
-3145
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Wo die Navigation sitzt.
|
||||||
|
///
|
||||||
|
/// Eine Einstellung, kein fester Aufbau: auf einem großen Bildschirm ist eine
|
||||||
|
/// Leiste an der Seite richtig, auf einem kleinen nimmt sie Platz weg, den man
|
||||||
|
/// lieber dem Bild gäbe. Wer welche Form will, entscheidet nicht die App.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class NavigationSettings {
|
||||||
|
enum Placement: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
/// Seitlich — auf dem iPhone ausfahrbar, auf dem iPad dauerhaft.
|
||||||
|
case side
|
||||||
|
/// Oben als Reihe über dem Inhalt.
|
||||||
|
case top
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let placement = "flixy.navigation.placement"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
var placement: Placement {
|
||||||
|
didSet { defaults.set(placement.rawValue, forKey: Key.placement) }
|
||||||
|
}
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.placement = defaults.string(forKey: Key.placement)
|
||||||
|
.flatMap(Placement.init(rawValue:)) ?? .side
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ struct RootView: View {
|
|||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
|
@Environment(NavigationSettings.self) private var navigation
|
||||||
|
|
||||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||||
///
|
///
|
||||||
@@ -142,13 +143,44 @@ struct RootView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var library: some View {
|
private var library: some View {
|
||||||
if sizeClass == .regular {
|
switch navigation.placement {
|
||||||
splitLayout
|
case .top:
|
||||||
} else {
|
topLayout
|
||||||
drawerLayout
|
case .side:
|
||||||
|
if sizeClass == .regular {
|
||||||
|
splitLayout
|
||||||
|
} else {
|
||||||
|
drawerLayout
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Navigation als Reihe über dem Inhalt.
|
||||||
|
///
|
||||||
|
/// Ohne Seitenleiste und ohne Blatt: die Einträge stehen alle gleichzeitig
|
||||||
|
/// da. Auf einem Fernseher und auf einem breiten Fenster ist das die
|
||||||
|
/// natürlichere Form — und auf dem Handy für den, der lieber tippt als
|
||||||
|
/// wischt.
|
||||||
|
private var topLayout: some View {
|
||||||
|
NavigationStack {
|
||||||
|
destination
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
TopNavigationBar(items: allDestinations, selection: $selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Einträge in einer Reihe — für die Navigation oben, die keine
|
||||||
|
/// Abschnitte kennt.
|
||||||
|
private var allDestinations: [SidebarDestination] {
|
||||||
|
var items: [SidebarDestination] = [.home]
|
||||||
|
items.append(contentsOf: sidebarDestinations)
|
||||||
|
if session.supportsLiveTV { items.append(.liveTV) }
|
||||||
|
items.append(contentsOf: [.downloads, .settings])
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
private var sidebarDestinations: [SidebarDestination] {
|
private var sidebarDestinations: [SidebarDestination] {
|
||||||
session.libraries.map { .library(key: $0.id, title: $0.title) }
|
session.libraries.map { .library(key: $0.id, title: $0.title) }
|
||||||
}
|
}
|
||||||
@@ -157,6 +189,7 @@ struct RootView: View {
|
|||||||
private var splitLayout: some View {
|
private var splitLayout: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SidebarView(
|
SidebarView(
|
||||||
|
presentation: .column,
|
||||||
libraries: sidebarDestinations,
|
libraries: sidebarDestinations,
|
||||||
showsLiveTV: session.supportsLiveTV,
|
showsLiveTV: session.supportsLiveTV,
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import FlixyDesign
|
|||||||
/// lässt sie sich auf dem iPhone in den Drawer und auf dem iPad in die
|
/// lässt sie sich auf dem iPhone in den Drawer und auf dem iPad in die
|
||||||
/// `NavigationSplitView` stecken, ohne etwas zu ändern.
|
/// `NavigationSplitView` stecken, ohne etwas zu ändern.
|
||||||
struct SidebarView: View {
|
struct SidebarView: View {
|
||||||
|
/// Wo diese Leiste steckt.
|
||||||
|
///
|
||||||
|
/// In der geteilten Ansicht bringt das System die Glasfläche selbst mit —
|
||||||
|
/// eine eigene läge darüber und machte sie stumpf. Im ausfahrbaren Blatt
|
||||||
|
/// gibt es keine, also kommt sie von hier.
|
||||||
|
enum Presentation { case drawer, column }
|
||||||
|
|
||||||
|
var presentation: Presentation = .drawer
|
||||||
let libraries: [SidebarDestination]
|
let libraries: [SidebarDestination]
|
||||||
/// Ob der aktive Server Live-TV anbietet.
|
/// Ob der aktive Server Live-TV anbietet.
|
||||||
///
|
///
|
||||||
@@ -40,10 +48,7 @@ struct SidebarView: View {
|
|||||||
.padding(.vertical, FlixySpacing.section)
|
.padding(.vertical, FlixySpacing.section)
|
||||||
}
|
}
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
// Milchig statt undurchsichtig: der Inhalt dahinter bleibt zu ahnen,
|
.modifier(SidebarBackground(presentation: presentation))
|
||||||
// und die Leiste wirkt wie eine Schicht darüber statt wie eine zweite
|
|
||||||
// Seite daneben. Ein Vollton stand vorher wie eine Wand im Bild.
|
|
||||||
.background(.ultraThinMaterial)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
@@ -106,3 +111,31 @@ private struct SidebarRow: View {
|
|||||||
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Fläche hinter der Seitenleiste.
|
||||||
|
///
|
||||||
|
/// Ein eigener Modifikator, weil die beiden Fälle verschiedene Ergebnistypen
|
||||||
|
/// haben und ein `if` im Ansichtskörper sie nicht zusammenbringt.
|
||||||
|
private struct SidebarBackground: ViewModifier {
|
||||||
|
let presentation: SidebarView.Presentation
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
switch presentation {
|
||||||
|
case .drawer:
|
||||||
|
// Die zum Inhalt zeigende Kante ist gerundet: so liegt das Blatt
|
||||||
|
// sichtbar **über** der Seite, statt eine zweite Seite daneben zu
|
||||||
|
// sein. Ein Vollton stand vorher wie eine Wand im Bild.
|
||||||
|
content.glassEffect(
|
||||||
|
.regular,
|
||||||
|
in: .rect(
|
||||||
|
topLeadingRadius: 0,
|
||||||
|
bottomLeadingRadius: 0,
|
||||||
|
bottomTrailingRadius: FlixyRadius.sheet,
|
||||||
|
topTrailingRadius: FlixyRadius.sheet
|
||||||
|
)
|
||||||
|
)
|
||||||
|
case .column:
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlixyDesign
|
||||||
|
|
||||||
|
/// Die Navigation als Reihe über dem Inhalt.
|
||||||
|
///
|
||||||
|
/// Dieselben Einträge wie in der Seitenleiste, nur nebeneinander statt
|
||||||
|
/// untereinander. Sie scrollt waagerecht, weil die Zahl der Mediatheken vom
|
||||||
|
/// Server kommt und niemand weiß, wie viele es sind — zusammengequetschte
|
||||||
|
/// Beschriftungen wären die schlechtere Antwort als eine Reihe, die weiterläuft.
|
||||||
|
struct TopNavigationBar: View {
|
||||||
|
let items: [SidebarDestination]
|
||||||
|
@Binding var selection: SidebarDestination
|
||||||
|
|
||||||
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
@Namespace private var glass
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
// Ein gemeinsamer Behälter: nebeneinanderliegende Glasflächen
|
||||||
|
// verschmelzen darin, statt als einzelne Scheiben übereinander zu
|
||||||
|
// liegen.
|
||||||
|
GlassEffectContainer(spacing: FlixySpacing.snug) {
|
||||||
|
HStack(spacing: FlixySpacing.snug) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
chip(for: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||||
|
.padding(.vertical, FlixySpacing.snug)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.scrollClipDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(for item: SidebarDestination) -> some View {
|
||||||
|
let isSelected = item == selection
|
||||||
|
|
||||||
|
return Button {
|
||||||
|
selection = item
|
||||||
|
} label: {
|
||||||
|
Label(item.sidebarLabel, systemImage: item.symbol)
|
||||||
|
.font(.flixyCardTitle)
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(isSelected ? accent : Color.flixyTextSecondary)
|
||||||
|
.padding(.horizontal, FlixySpacing.regular)
|
||||||
|
.padding(.vertical, FlixySpacing.snug)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.glassEffect(
|
||||||
|
isSelected ? .regular.tint(accent.opacity(0.28)).interactive() : .regular.interactive(),
|
||||||
|
in: .capsule
|
||||||
|
)
|
||||||
|
.glassEffectID(item, in: glass)
|
||||||
|
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import FlixyDesign
|
|||||||
/// stillschweigend zu verändern.
|
/// stillschweigend zu verändern.
|
||||||
struct AppearanceView: View {
|
struct AppearanceView: View {
|
||||||
@Environment(FlixyTheme.self) private var theme
|
@Environment(FlixyTheme.self) private var theme
|
||||||
|
@Environment(NavigationSettings.self) private var navigation
|
||||||
@State private var customHex: String = ""
|
@State private var customHex: String = ""
|
||||||
@State private var showsCustomField = false
|
@State private var showsCustomField = false
|
||||||
|
|
||||||
@@ -16,6 +17,20 @@ struct AppearanceView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
Section {
|
||||||
|
Picker("appearance.navigation", selection: Bindable(navigation).placement) {
|
||||||
|
ForEach(NavigationSettings.Placement.allCases) { placement in
|
||||||
|
Text(placement.label).tag(placement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.vertical, FlixySpacing.tight)
|
||||||
|
} header: {
|
||||||
|
Text("appearance.navigation")
|
||||||
|
} footer: {
|
||||||
|
Text("appearance.navigationExplanation")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
LazyVGrid(columns: columns, spacing: FlixySpacing.regular) {
|
LazyVGrid(columns: columns, spacing: FlixySpacing.regular) {
|
||||||
ForEach(AccentOption.palette) { option in
|
ForEach(AccentOption.palette) { option in
|
||||||
@@ -142,3 +157,12 @@ private struct PreviewRow: View {
|
|||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.flixySurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NavigationSettings.Placement {
|
||||||
|
var label: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .side: "appearance.navigationSide"
|
||||||
|
case .top: "appearance.navigationTop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,40 +54,66 @@ public struct JellyfinBackend: MediaBackend {
|
|||||||
// wird aus einzelnen Abfragen zusammengesetzt. Das ist kein Mangel:
|
// wird aus einzelnen Abfragen zusammengesetzt. Das ist kein Mangel:
|
||||||
// so steht hier, was die Reihen bedeuten, statt dass es der Server
|
// so steht hier, was die Reihen bedeuten, statt dass es der Server
|
||||||
// entscheidet.
|
// entscheidet.
|
||||||
// „Zuletzt hinzugefügt" wird streng geholt: scheitert es, ist der
|
//
|
||||||
// Server nicht erreichbar, und dann gehört eine Fehlermeldung hin.
|
// Die Mediatheken werden streng geholt: sie sind die Probe darauf, ob
|
||||||
let latest = try await latestItems(limit: 20)
|
// der Server überhaupt antwortet. Alles Weitere ist nachsichtig —
|
||||||
|
// fällt „Als Nächstes" aus, soll deshalb nicht die ganze Startseite
|
||||||
|
// leer bleiben.
|
||||||
|
let libraries = try await libraries().filter(\.isSupported)
|
||||||
|
|
||||||
// Die beiden anderen nachsichtig: fällt „Als Nächstes" aus, soll
|
async let resuming = try? rowItems(path: "/Users/\(account.userID)/Items/Resume", query: [
|
||||||
// deshalb nicht die ganze Startseite leer bleiben.
|
|
||||||
let resuming = try? await rowItems(path: "/Users/\(account.userID)/Items/Resume", query: [
|
|
||||||
URLQueryItem(name: "Limit", value: "20"),
|
URLQueryItem(name: "Limit", value: "20"),
|
||||||
URLQueryItem(name: "MediaTypes", value: "Video"),
|
URLQueryItem(name: "MediaTypes", value: "Video"),
|
||||||
URLQueryItem(name: "Fields", value: Self.fields),
|
URLQueryItem(name: "Fields", value: Self.fields),
|
||||||
])
|
])
|
||||||
let nextUp = try? await rowItems(path: "/Shows/NextUp", query: [
|
async let nextUp = try? rowItems(path: "/Shows/NextUp", query: [
|
||||||
URLQueryItem(name: "userId", value: account.userID),
|
URLQueryItem(name: "userId", value: account.userID),
|
||||||
URLQueryItem(name: "Limit", value: "20"),
|
URLQueryItem(name: "Limit", value: "20"),
|
||||||
URLQueryItem(name: "Fields", value: Self.fields),
|
URLQueryItem(name: "Fields", value: Self.fields),
|
||||||
])
|
])
|
||||||
|
async let favourites = try? rowItems(path: "/Users/\(account.userID)/Items", query: [
|
||||||
|
URLQueryItem(name: "Filters", value: "IsFavorite"),
|
||||||
|
URLQueryItem(name: "Recursive", value: "true"),
|
||||||
|
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"),
|
||||||
|
URLQueryItem(name: "Limit", value: "20"),
|
||||||
|
URLQueryItem(name: "Fields", value: Self.fields),
|
||||||
|
])
|
||||||
|
|
||||||
let rows = [
|
var rows = [
|
||||||
MediaRowGroup(
|
MediaRowGroup(
|
||||||
id: "resume",
|
id: "resume",
|
||||||
title: String(localized: "row.continueWatching", bundle: .module),
|
title: String(localized: "row.continueWatching", bundle: .module),
|
||||||
items: resuming ?? []
|
items: await resuming ?? []
|
||||||
),
|
),
|
||||||
MediaRowGroup(
|
MediaRowGroup(
|
||||||
id: "nextup",
|
id: "nextup",
|
||||||
title: String(localized: "row.nextUp", bundle: .module),
|
title: String(localized: "row.nextUp", bundle: .module),
|
||||||
items: nextUp ?? []
|
items: await nextUp ?? []
|
||||||
),
|
|
||||||
MediaRowGroup(
|
|
||||||
id: "latest",
|
|
||||||
title: String(localized: "row.recentlyAdded", bundle: .module),
|
|
||||||
items: latest
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Je Mediathek eine eigene Reihe. „Neu in Filme" und „Neu in Serien"
|
||||||
|
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
||||||
|
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
||||||
|
// an Folgen vorbei.
|
||||||
|
for library in libraries {
|
||||||
|
let items = try? await latestItems(limit: 20, inLibrary: library.id)
|
||||||
|
rows.append(MediaRowGroup(
|
||||||
|
id: "latest.\(library.id)",
|
||||||
|
title: String(
|
||||||
|
localized: "row.latestIn \(library.title)",
|
||||||
|
bundle: .module
|
||||||
|
),
|
||||||
|
items: items ?? []
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.append(MediaRowGroup(
|
||||||
|
id: "favourites",
|
||||||
|
title: String(localized: "row.favourites", bundle: .module),
|
||||||
|
items: await favourites ?? []
|
||||||
|
))
|
||||||
|
|
||||||
// Leere Reihen wären auf der Startseite nur Überschriften ohne Inhalt.
|
// Leere Reihen wären auf der Startseite nur Überschriften ohne Inhalt.
|
||||||
return rows.filter { !$0.items.isEmpty }
|
return rows.filter { !$0.items.isEmpty }
|
||||||
}
|
}
|
||||||
@@ -330,15 +356,21 @@ public struct JellyfinBackend: MediaBackend {
|
|||||||
///
|
///
|
||||||
/// Die Antwort ist hier ausnahmsweise ein nacktes Feld, kein Objekt mit
|
/// Die Antwort ist hier ausnahmsweise ein nacktes Feld, kein Objekt mit
|
||||||
/// `Items` darin.
|
/// `Items` darin.
|
||||||
private func latestItems(limit: Int) async throws(MediaBackendError) -> [MediaItem] {
|
private func latestItems(
|
||||||
|
limit: Int,
|
||||||
|
inLibrary libraryID: String? = nil
|
||||||
|
) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
|
var query = [
|
||||||
|
URLQueryItem(name: "Limit", value: String(limit)),
|
||||||
|
URLQueryItem(name: "Fields", value: Self.fields),
|
||||||
|
// Neue Folgen erscheinen als ihre Serie statt als zwölf
|
||||||
|
// Einzelkacheln, die alle dasselbe Plakat tragen.
|
||||||
|
URLQueryItem(name: "GroupItems", value: "true"),
|
||||||
|
]
|
||||||
|
if let libraryID { query.append(URLQueryItem(name: "ParentId", value: libraryID)) }
|
||||||
|
|
||||||
let items = try await client.send(
|
let items = try await client.send(
|
||||||
JellyfinRequest(path: "/Users/\(account.userID)/Items/Latest", query: [
|
JellyfinRequest(path: "/Users/\(account.userID)/Items/Latest", query: query),
|
||||||
URLQueryItem(name: "Limit", value: String(limit)),
|
|
||||||
URLQueryItem(name: "Fields", value: Self.fields),
|
|
||||||
// Neue Folgen erscheinen als ihre Serie statt als zwölf
|
|
||||||
// Einzelkacheln, die alle dasselbe Plakat tragen.
|
|
||||||
URLQueryItem(name: "GroupItems", value: "true"),
|
|
||||||
]),
|
|
||||||
as: [JellyfinItem].self
|
as: [JellyfinItem].self
|
||||||
)
|
)
|
||||||
return items.map { $0.asMediaItem(imageBase: images) }
|
return items.map { $0.asMediaItem(imageBase: images) }
|
||||||
|
|||||||
@@ -34,6 +34,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"row.favourites": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Favoriten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Favourites"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"row.latestIn %@": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Neu in %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "New in %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"row.nextUp": {
|
"row.nextUp": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
Reference in New Issue
Block a user