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]