Phase 1: Anmeldung, Bibliothek, Detailseiten, Hybrid-Player
Die Datenschicht: - plex.tv-Anmeldung über das PIN-Verfahren, Token im Schlüsselbund - Serversuche und Verbindungsauswahl nach Rang (lokal vor direkt vor Vermittlung), mit Abgleich der Server-Kennung gegen veraltete plex.direct-Adressen - PlexServer als einzige Stelle, an der die App Endpunkte kennt: Mediatheken, Hubs, seitenweises Blättern mit Filtern, Metadaten, Staffeln/Folgen, Suche Die Oberfläche: - Startseite mit Aufmacher und den Reihen vom Server - Mediathek als Raster oder Liste, mit Filter- und Sortierblatt - Detailseiten für Filme, Folgen, Serien und Staffeln inkl. Besetzung, Fassungsauswahl und aufklappbaren technischen Angaben - Suche mit gemerkten Anfragen - Vollbildplayer mit eigenem Chrome Der Player: - AVPlayerEngine und VLCPlayerEngine hinter einem gemeinsamen Protokoll - VLCKit liegt in einem eigenen Package (FlixyVLC), damit `swift test` in FlixyKit nicht das 920-MB-Binärpaket mitzieht Am echten plex.tv geprüft: der Anmeldecode kommt an. Dabei fiel auf, dass `strong=true` einen dreißigstelligen Code liefert, der sich bei plex.tv/link nicht eintippen lässt — jetzt der kurze. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// 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
|
||||
|
||||
@State private var text = ""
|
||||
@State private var hubs: [PlexHub] = []
|
||||
@State private var isSearching = false
|
||||
@State private var recent: [String] = UserDefaults.standard.stringArray(forKey: recentKey) ?? []
|
||||
|
||||
private static let recentKey = "flixy.search.recent"
|
||||
private static let recentLimit = 8
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if text.isEmpty {
|
||||
recentSearches
|
||||
} else if hubs.isEmpty && !isSearching {
|
||||
ContentUnavailableView.search(text: text)
|
||||
} else {
|
||||
results
|
||||
}
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
.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.
|
||||
.task(id: text) { await search() }
|
||||
}
|
||||
|
||||
private var results: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||
ForEach(hubs) { hub in
|
||||
MediaRow(hub: hub)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, FlixySpacing.loose)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recentSearches: some View {
|
||||
if recent.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("search.title", systemImage: "magnifyingglass")
|
||||
} description: {
|
||||
Text("search.hint")
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
ForEach(recent, id: \.self) { entry in
|
||||
Button {
|
||||
text = entry
|
||||
} label: {
|
||||
Label(entry, systemImage: "clock.arrow.circlepath")
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
recent.remove(atOffsets: offsets)
|
||||
persistRecent()
|
||||
}
|
||||
} header: {
|
||||
Text("search.recent")
|
||||
}
|
||||
.listRowBackground(Color.flixySurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
private func search() async {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let server = session.server, trimmed.count >= 2 else {
|
||||
hubs = []
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
} catch {
|
||||
// Ein neuer Tastendruck hat diese Aufgabe abgebrochen — genau der
|
||||
// Zweck der Pause.
|
||||
return
|
||||
}
|
||||
|
||||
isSearching = true
|
||||
defer { isSearching = false }
|
||||
|
||||
do {
|
||||
hubs = try await server.search(trimmed)
|
||||
if !hubs.isEmpty { remember(trimmed) }
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
hubs = []
|
||||
}
|
||||
}
|
||||
|
||||
private func remember(_ entry: String) {
|
||||
recent.removeAll { $0.caseInsensitiveCompare(entry) == .orderedSame }
|
||||
recent.insert(entry, at: 0)
|
||||
recent = Array(recent.prefix(Self.recentLimit))
|
||||
persistRecent()
|
||||
}
|
||||
|
||||
private func persistRecent() {
|
||||
UserDefaults.standard.set(recent, forKey: Self.recentKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user