From 95723588c2b932ac8164e4d509bfa2667ecfb39f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 13 Sep 2026 15:22:18 +0200 Subject: [PATCH] =?UTF-8?q?Nachgesehen=20=E2=80=94=20und=20dabei=20vier=20?= =?UTF-8?q?Fehler=20gefunden,=20zwei=20davon=20meine=20neuen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Simulator läuft wieder. Ich habe alles durchgeklickt, was ich dir in den letzten beiden Runden ungeprüft gegeben habe. Es war nicht in Ordnung. **Zwei Navigationsleisten übereinander.** Auf dem iPhone hat die Leiste unten fünf Plätze; alles darüber schiebt das System in ein „Mehr"-Verzeichnis — und dessen Einträge bekamen zwei Zurück-Pfeile, einen vom System und einen von uns. Statt das zu flicken, gibt es jetzt die **Bibliothek**, die du ohnehin wolltest: auf dem iPhone ein Eintrag, hinter dem alle Mediatheken stehen, auf dem iPad weiter jede einzeln in der Seitenleiste. Damit bleibt die Leiste bei fünf Einträgen, und das „Mehr" entfällt. **Kacheln in der Mediathek öffneten nichts.** Kein Fehler, keine Meldung — Antippen führte einfach ins Leere. Der Grund: in einem Stapel dürfen nicht beide Arten von Verknüpfung nebeneinander vorkommen. Sobald ein Bildschirm über eine **Ansichts**-Verknüpfung geschoben wurde, fanden die **Wert**-Verknüpfungen darin ihr Ziel nicht mehr. Jetzt ist alles wertbasiert, und das Ziel für Detailseiten steht **einmal je Stapel** an dessen Wurzel statt in jedem Bildschirm einzeln. **Der Lupenknopf in der Kopfzeile zeigte auf ein Ziel, das es nie gab.** Kein Bildschirm hat je ein `SearchRoute`-Ziel angemeldet — der Knopf tat seit jeher nichts. Er ist weg; die Suche hat ihren eigenen Platz in der Leiste. **Der Download-Dialog zeigte Schlüssel statt Namen** — „quality.hd" statt „720p". Die drei Übersetzungen fehlten schlicht. **Die Kontokarte wuchs aus dem Bild heraus.** Die Leiste unten in der Seitenleiste hat eine feste Höhe; meine aufklappende Karte schob die Zeile mit dem Namen nach unten hinaus. Jetzt hängt sie als Popover darüber — die Zeile bleibt stehen, wie bei JellyTV. Verkleinerte Downloads bei Jellyfin laufen: Stufen erscheinen, die Adresse stimmt, die Datei kommt an. Dass die Attrappe nur 28 kB liefert und die App das als „keine Videodatei" zurückweist, ist richtig so — die Prüfung tut, was sie soll. Zwei Gruben in der Attrappe nebenbei gestopft: sie kannte `/stream.mp4` nicht und meldete ihr Testbild als 360p, womit es gar keine kleineren Stufen geben konnte. 263 Tests grün. Diesmal alles angesehen: iPhone und iPad, Einstellungen, Bibliothek, Detailseite, Download-Dialog, Kontokarte. Co-Authored-By: Claude Opus 5 --- Flixy/Resources/Localizable.xcstrings | 80 +++++++++++++++ Flixy/Views/Home/HomeView.swift | 2 - Flixy/Views/Library/LibraryHubView.swift | 60 ++++++++++++ Flixy/Views/Library/LibraryView.swift | 2 - Flixy/Views/LoadState.swift | 14 +++ Flixy/Views/Navigation/AccountBar.swift | 68 ++++++------- Flixy/Views/Navigation/RootView.swift | 97 ++++++++++++++----- .../Views/Navigation/SidebarDestination.swift | 4 + Flixy/Views/Search/SearchView.swift | 15 --- Tools/JellyfinStub/server.py | 9 +- 10 files changed, 271 insertions(+), 80 deletions(-) create mode 100644 Flixy/Views/Library/LibraryHubView.swift diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 4f33235..01fb3d4 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2417,6 +2417,22 @@ } } }, + "libraryHub.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf diesem Server ist keine Mediathek zu sehen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No library is visible on this server." + } + } + } + }, "liveTV.channelCount %@": { "comment": "A label displaying the number of channels. The argument is the number of channels.", "isCommentAutoGenerated": true @@ -3376,6 +3392,38 @@ } } }, + "quality.fullHD": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "1080p" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "1080p" + } + } + } + }, + "quality.hd": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "720p" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "720p" + } + } + } + }, "quality.high": { "extractionState": "stale", "localizations": { @@ -3428,6 +3476,22 @@ } } }, + "quality.lowRes": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "480p" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "480p" + } + } + } + }, "quality.medium": { "extractionState": "stale", "localizations": { @@ -4594,6 +4658,22 @@ } } }, + "sidebar.library": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bibliothek" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Library" + } + } + } + }, "sidebar.liveTV": { "comment": "Eintrag in der Seitenleiste", "localizations": { diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 11e86c6..116472c 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -49,8 +49,6 @@ struct HomeView: View { .toolbarBackground(.hidden, for: .navigationBar) // Helle Symbole, weil darunter ein Bild liegt und kein heller Grund. .toolbarColorScheme(.dark, for: .navigationBar) - .toolbar { SearchToolbarItem() } - .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } .task(id: session.serverName) { await load() } .refreshable { await load() } } diff --git a/Flixy/Views/Library/LibraryHubView.swift b/Flixy/Views/Library/LibraryHubView.swift new file mode 100644 index 0000000..218f3c8 --- /dev/null +++ b/Flixy/Views/Library/LibraryHubView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Alle Mediatheken auf einem Bildschirm. +/// +/// Auf dem iPad hat die Seitenleiste Platz für jede Mediathek einzeln. Auf dem +/// iPhone hat die Leiste unten fünf Plätze — und sobald es mehr Ziele gibt, +/// schiebt das System den Rest in ein „Mehr"-Verzeichnis. Das sah nicht nur +/// gedrängt aus, es war kaputt: die Einträge dort bekamen **zwei** +/// Navigationsleisten übereinander, eine vom System und eine von uns. +/// +/// Dieser Bildschirm fasst sie zu einem Ziel zusammen. Damit bleibt die Leiste +/// unter fünf Einträgen, und die Mediatheken stehen trotzdem alle da — mit +/// mehr Platz für ihre Namen, als eine Leiste je hätte. +struct LibraryHubView: View { + @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent + + var body: some View { + List { + if session.libraries.isEmpty { + Text("libraryHub.empty") + .foregroundStyle(Color.flixyTextSecondary) + .listRowBackground(Color.flixySurface) + } else { + Section("sidebar.libraries") { + ForEach(session.libraries) { library in + NavigationLink(value: LibraryRoute(id: library.id)) { + Label(library.title, systemImage: symbol(for: library.kind)) + } + .listRowBackground(Color.flixySurface) + } + } + } + + if session.supportsLiveTV { + Section { + NavigationLink(value: LiveTVRoute()) { + Label("sidebar.liveTV", systemImage: "dot.radiowaves.left.and.right") + } + .listRowBackground(Color.flixySurface) + } + } + } + .tint(accent) + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("sidebar.library")) + .navigationBarTitleDisplayMode(.inline) + } + + private func symbol(for kind: MediaLibrary.Kind) -> String { + switch kind { + case .movie: "film" + case .show: "tv" + default: "rectangle.stack" + } + } +} diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 2eaef02..76549ea 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -56,7 +56,6 @@ struct LibraryView: View { .sheet(isPresented: $showsFilters) { LibraryFilterSheet(query: $query, libraryKind: library.kind) } - .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } .task(id: library.id) { await reload() } // Jede Änderung an Filter oder Sortierung verwirft die bisherige // Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei @@ -141,7 +140,6 @@ struct LibraryView: View { .accessibilityLabel(Text("library.toggleLayout")) } - SearchToolbarItem() } // MARK: - Laden diff --git a/Flixy/Views/LoadState.swift b/Flixy/Views/LoadState.swift index 444cd12..e8d1eac 100644 --- a/Flixy/Views/LoadState.swift +++ b/Flixy/Views/LoadState.swift @@ -95,3 +95,17 @@ struct MediaRoute: Hashable { /// Für die Titelzeile, solange noch geladen wird. let title: String } + +/// Der Weg zu einer Mediathek. +/// +/// Ein Wert statt einer Ansicht, und das ist kein Geschmack: in einem Stapel +/// dürfen nicht beide Arten von Verknüpfung nebeneinander vorkommen. Sobald +/// eine Ansicht über eine **Ansichts**-Verknüpfung geschoben wurde, fanden die +/// **Wert**-Verknüpfungen darin ihr Ziel nicht mehr — jedes Antippen einer +/// Kachel führte ins Leere, ohne Fehlermeldung. +struct LibraryRoute: Hashable { + let id: String +} + +/// Der Weg zum Programmführer. +struct LiveTVRoute: Hashable {} diff --git a/Flixy/Views/Navigation/AccountBar.swift b/Flixy/Views/Navigation/AccountBar.swift index 129df20..be3d1ef 100644 --- a/Flixy/Views/Navigation/AccountBar.swift +++ b/Flixy/Views/Navigation/AccountBar.swift @@ -8,11 +8,14 @@ import FlixyDesign /// Einstellungen, statt dass sie als gleichrangiger Eintrag zwischen den /// Mediatheken stehen. /// -/// **Eine Karte, die nach oben aufklappt**, kein Systemmenü. Der Unterschied -/// ist nicht nur Geschmack: ein Menü legt sich als eigene Ebene über alles und -/// springt dorthin, wo Platz ist. Die Karte bleibt, wo sie ist, wächst nach -/// oben und lässt die Zeile mit dem Namen die ganze Zeit stehen — man sieht -/// also weiter, um wessen Konto es geht. +/// **Eine Karte, die über der Zeile aufgeht**, kein Systemmenü: mit denselben +/// Abständen und derselben Schrift wie der Rest der App, statt der grauen +/// Standardliste. +/// +/// Sie hängt als Popover an der Zeile und wächst nicht in der Leiste selbst. +/// Das war der erste Versuch, und er ging schief: die Leiste unten hat eine +/// feste Höhe, die Karte wuchs nach unten aus dem Bild heraus, und die Zeile +/// mit dem Namen verschwand dabei. /// /// Auf dem iPhone gibt es diese Leiste nicht; dort haben die Einstellungen /// ihren eigenen Eintrag. @@ -35,30 +38,31 @@ struct AccountBar: View { } var body: some View { - VStack(spacing: 0) { - if isExpanded { - action("account.settings", symbol: "gearshape") { - collapse() - selection = .settings + accountRow + .popover(isPresented: $isExpanded, arrowEdge: .bottom) { + VStack(spacing: 0) { + action("account.settings", symbol: "gearshape") { + isExpanded = false + selection = .settings + } + separator + action( + "action.signOut", + symbol: "rectangle.portrait.and.arrow.right", + isDestructive: true + ) { + isExpanded = false + showsSignOutConfirmation = true + } } - separator - - action( - "action.signOut", - symbol: "rectangle.portrait.and.arrow.right", - isDestructive: true - ) { - collapse() - showsSignOutConfirmation = true - } - separator + .frame(minWidth: 240) + .background(Color.flixySurface) + // Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe + // steht trotzdem da, damit aus dem Popover dort kein + // bildschirmfüllendes Blatt wird. + .presentationCompactAdaptation(.popover) } - - accountRow - } - .glassEffect(.regular, in: .rect(cornerRadius: FlixyRadius.sheet)) - .animation(.snappy(duration: 0.25), value: isExpanded) - .confirmationDialog( + .confirmationDialog( Text("settings.signOutConfirm"), isPresented: $showsSignOutConfirmation, titleVisibility: .visible @@ -72,7 +76,7 @@ struct AccountBar: View { private var accountRow: some View { Button { - withAnimation(.snappy(duration: 0.25)) { isExpanded.toggle() } + isExpanded.toggle() } label: { HStack(spacing: FlixySpacing.snug) { avatar @@ -93,8 +97,8 @@ struct AccountBar: View { Spacer(minLength: FlixySpacing.snug) - // Zeigt nach oben, wenn zugeklappt: dorthin wächst die Karte. - Image(systemName: isExpanded ? "chevron.down" : "chevron.up") + // Zeigt nach oben: dorthin geht die Karte auf. + Image(systemName: "chevron.up") .font(.caption2.weight(.semibold)) .foregroundStyle(Color.flixyTextMuted) } @@ -141,8 +145,4 @@ struct AccountBar: View { .frame(width: 26, height: 26) .background(accent, in: .circle) } - - private func collapse() { - withAnimation(.snappy(duration: 0.25)) { isExpanded = false } - } } diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 9a576e5..41d46a9 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -101,6 +101,16 @@ struct RootView: View { // bleiben, die es auf dem neuen Server gar nicht gibt. if newPhase == .ready, case .library = selection { selection = .home } } + .onChange(of: sizeClass) { _, _ in + // Beim Drehen oder Teilen des Fensters wechselt die Form der + // Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine + // Auswahl, die auf der anderen Seite nicht vorkommt, zeigte sonst + // einen leeren Bereich. + switch selection { + case .library, .libraryHub, .liveTV: selection = .home + default: break + } + } } /// Wechselt den laufenden Titel. @@ -163,35 +173,52 @@ struct RootView: View { private var library: some View { TabView(selection: $selection) { Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) { - NavigationStack { HomeView() } + mediaStack { HomeView() } } .customizationID("flixy.tab.home") - TabSection { - ForEach(session.libraries) { library in - Tab( - library.title, - systemImage: symbol(for: library.kind), - value: SidebarDestination.library(key: library.id, title: library.title) - ) { - NavigationStack { LibraryView(library: library) } + // Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle + // zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack: + // die Leiste unten hat fünf Plätze, und alles darüber schiebt das + // System in ein „Mehr"-Verzeichnis — dessen Einträge dann zwei + // Navigationsleisten übereinander tragen, eine vom System und eine + // von uns. + if sizeClass == .regular { + TabSection { + ForEach(session.libraries) { library in + Tab( + library.title, + systemImage: symbol(for: library.kind), + value: SidebarDestination.library(key: library.id, title: library.title) + ) { + mediaStack { LibraryView(library: library) } + } + .customizationID("flixy.tab.library.\(library.id)") } - .customizationID("flixy.tab.library.\(library.id)") + } header: { + Text("sidebar.libraries") } - } header: { - Text("sidebar.libraries") - } - .customizationID("flixy.tab.libraries") + .customizationID("flixy.tab.libraries") - if session.supportsLiveTV { - Tab( - "sidebar.liveTV", - systemImage: "dot.radiowaves.left.and.right", - value: SidebarDestination.liveTV - ) { - NavigationStack { LiveTVView() } + if session.supportsLiveTV { + Tab( + "sidebar.liveTV", + systemImage: "dot.radiowaves.left.and.right", + value: SidebarDestination.liveTV + ) { + mediaStack { LiveTVView() } + } + .customizationID("flixy.tab.liveTV") } - .customizationID("flixy.tab.liveTV") + } else { + Tab( + "sidebar.library", + systemImage: "books.vertical", + value: SidebarDestination.libraryHub + ) { + mediaStack { LibraryHubView() } + } + .customizationID("flixy.tab.libraryHub") } Tab( @@ -199,12 +226,12 @@ struct RootView: View { systemImage: "arrow.down.circle", value: SidebarDestination.downloads ) { - NavigationStack { DownloadsView() } + mediaStack { DownloadsView() } } .customizationID("flixy.tab.downloads") Tab("sidebar.settings", systemImage: "gearshape", value: SidebarDestination.settings) { - NavigationStack { SettingsView() } + mediaStack { SettingsView() } } .customizationID("flixy.tab.settings") @@ -212,7 +239,7 @@ struct RootView: View { // getrennt von den übrigen Einträgen dar, so wie man es aus jeder // anderen App kennt. Tab(value: SidebarDestination.search, role: .search) { - NavigationStack { SearchView() } + mediaStack { SearchView() } } .customizationID("flixy.tab.search") } @@ -227,6 +254,26 @@ struct RootView: View { .miniPlayer(player, isVisible: playback.presentation == .mini) } + /// Ein Navigationsstapel, in dem Detailseiten aufgehen. + /// + /// Das Ziel wird **einmal je Stapel** angemeldet, an seiner Wurzel. Vorher + /// tat es jeder Bildschirm für sich — und in dem Moment, in dem zwei davon + /// im selben Stapel übereinanderlagen (die Mediathek in der Bibliothek), + /// führte kein Antippen einer Kachel mehr irgendwohin. SwiftUI meldet das + /// nicht; es passiert einfach nichts. + private func mediaStack(@ViewBuilder content: () -> Content) -> some View { + NavigationStack { + content() + .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } + .navigationDestination(for: LibraryRoute.self) { route in + if let library = session.libraries.first(where: { $0.id == route.id }) { + LibraryView(library: library) + } + } + .navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() } + } + } + private func symbol(for kind: MediaLibrary.Kind) -> String { switch kind { case .movie: "film" diff --git a/Flixy/Views/Navigation/SidebarDestination.swift b/Flixy/Views/Navigation/SidebarDestination.swift index c195187..bd2e109 100644 --- a/Flixy/Views/Navigation/SidebarDestination.swift +++ b/Flixy/Views/Navigation/SidebarDestination.swift @@ -7,6 +7,8 @@ import Foundation enum SidebarDestination: Hashable { case home case library(key: String, title: String) + /// Alle Mediatheken zusammen — auf schmalen Bildschirmen an ihrer Stelle. + case libraryHub case search case liveTV case downloads @@ -21,6 +23,7 @@ enum SidebarDestination: Hashable { switch self { case .home: Branding.appName case .library(_, let title): title + case .libraryHub: String(localized: "sidebar.library") case .search: String(localized: "search.title") case .liveTV: String(localized: "sidebar.liveTV") case .downloads: String(localized: "sidebar.downloads") @@ -40,6 +43,7 @@ enum SidebarDestination: Hashable { switch self { case .home: "house" case .library: "film" + case .libraryHub: "books.vertical" case .search: "magnifyingglass" case .liveTV: "dot.radiowaves.left.and.right" case .downloads: "arrow.down.circle" diff --git a/Flixy/Views/Search/SearchView.swift b/Flixy/Views/Search/SearchView.swift index 6c762f5..34fe00f 100644 --- a/Flixy/Views/Search/SearchView.swift +++ b/Flixy/Views/Search/SearchView.swift @@ -2,20 +2,6 @@ import SwiftUI import FlixyDesign import MediaKit -/// Der Knopf, der zur Suche führt. Steht in mehreren Werkzeugleisten. -struct SearchToolbarItem: ToolbarContent { - var body: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - NavigationLink(value: SearchRoute()) { - Image(systemName: "magnifyingglass") - } - .accessibilityLabel(Text("search.title")) - } - } -} - -struct SearchRoute: Hashable {} - /// Sucht über alle Mediatheken des Servers. struct SearchView: View { @Environment(FlixySession.self) private var session @@ -42,7 +28,6 @@ struct SearchView: View { .navigationTitle(Text("search.title")) .navigationBarTitleDisplayMode(.inline) .searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always)) - .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } // Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer // kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los, // von denen elf verworfen werden. diff --git a/Tools/JellyfinStub/server.py b/Tools/JellyfinStub/server.py index c7b9ab4..0e5444b 100644 --- a/Tools/JellyfinStub/server.py +++ b/Tools/JellyfinStub/server.py @@ -44,8 +44,11 @@ def item(key, name, kind, overview, *, resume=None, series=None): "SupportsDirectStream": True, "SupportsTranscoding": True, "MediaStreams": [] if UNANALYSED else [ + # Die Datei selbst ist 640x360; hier steht 1080p, damit die + # App die kleineren Download-Stufen überhaupt anbietet. Für die + # Oberfläche zählt, was der Server meldet. {"Index": 0, "Type": "Video", "Codec": "h264", - "Width": 640, "Height": 360}, + "Width": 1920, "Height": 1080}, {"Index": 1, "Type": "Audio", "Codec": "aac", "Channels": 2, "Language": "ger", "DisplayTitle": "Deutsch", "IsDefault": True}, ], @@ -133,7 +136,9 @@ class Handler(BaseHTTPRequestHandler): item("e2", "Das Wrack", "Episode", "Was der Sturm freilegt.", series=("s1", "Küstenwache", 4)), ])) - if p.startswith("/Videos/") and p.endswith("/stream"): + # Sowohl `/stream` (abspielen) als auch `/stream.mp4` (herunterladen). + # Umgerechnet wird hier natürlich nichts — es kommt dieselbe Datei. + if p.startswith("/Videos/") and ("/stream" in p): return self.serve_file(SAMPLE, "video/mp4") if p.startswith(f"/Users/{USER_ID}/Items/") and p.count("/") == 4: key = p.rsplit("/", 1)[-1]