From c521d8290d8ef399b7810cfe1394aaaa61f02680 Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Fri, 5 Jun 2026 01:49:05 -0400 Subject: [PATCH] Add search view --- .../LuminateCore/BaseItemDto+Display.swift | 11 ++ Sources/LuminateLibrary/SearchView.swift | 126 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 Sources/LuminateLibrary/SearchView.swift diff --git a/Sources/LuminateCore/BaseItemDto+Display.swift b/Sources/LuminateCore/BaseItemDto+Display.swift index 4abe0ac..a462944 100644 --- a/Sources/LuminateCore/BaseItemDto+Display.swift +++ b/Sources/LuminateCore/BaseItemDto+Display.swift @@ -4,6 +4,17 @@ extension Components.Schemas.BaseItemDto: Identifiable { public var id: String { Id ?? "" } } +public extension Components.Schemas.SearchHint { + var runtimeString: String { + guard let ticks = RunTimeTicks else { return "" } + let totalSeconds = Int(ticks / 10_000_000) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + if hours > 0 { return "\(hours)h \(minutes)m" } + return "\(minutes)m" + } +} + public extension Components.Schemas.BaseItemDto { var isMovie: Bool { _Type?.value1 == .Movie } var isSeries: Bool { _Type?.value1 == .Series } diff --git a/Sources/LuminateLibrary/SearchView.swift b/Sources/LuminateLibrary/SearchView.swift new file mode 100644 index 0000000..4d037d3 --- /dev/null +++ b/Sources/LuminateLibrary/SearchView.swift @@ -0,0 +1,126 @@ +import Adwaita +import LuminateCore + +struct SearchView: View { + + var client: JellyfinClient + var userId: String + @State private var searchText = "" + @State private var results: [Components.Schemas.SearchHint] = [] + @State private var isSearching = false + + var view: Body { + VStack { + SearchBar("Search", text: $searchText) + .onSubmit { + performSearch() + } + .onChange { + debounceSearch() + } + if isSearching { + Spinner() + .padding(20) + } else { + ScrollView { + VStack { + ForEach(results) { hint in + SearchResultRow(hint: hint, client: client) + } + } + } + } + } + .padding() + } + + private func debounceSearch() { + Task { + try? await Task.sleep(nanoseconds: 300_000_000) + if !searchText.isEmpty { + performSearch() + } + } + } + + private func performSearch() { + guard !searchText.isEmpty else { + results = [] + return + } + isSearching = true + Task { + let result = try? await client.getSearchHints( + searchTerm: searchText, + userId: userId, + limit: 50 + ) + await MainActor.run { + results = result?.SearchHints ?? [] + isSearching = false + } + } + } +} + +extension Components.Schemas.SearchHint: Identifiable { + public var id: String { Id ?? ItemId ?? UUID().uuidString } +} + +struct SearchResultRow: View { + + var hint: Components.Schemas.SearchHint + var client: JellyfinClient + @State private var imageData: Data? + + var view: Body { + HStack { + if let data = imageData { + Picture() + .data(data) + .frame(minWidth: 80, maxWidth: 80, minHeight: 120, maxHeight: 120) + .style("card") + } else { + Box(spacing: 0) {} + .frame(minWidth: 80, maxWidth: 80, minHeight: 120, maxHeight: 120) + .style("card") + } + VStack { + Text(hint.Name ?? "") + .style("body") + .halign(.start) + if let type = hint._Type?.value1 { + Text("\(type)") + .style("caption") + .dim() + .halign(.start) + } + if let year = hint.ProductionYear { + Text("\(year)") + .style("caption") + .halign(.start) + } + Text(hint.runtimeString) + .style("caption") + .halign(.start) + } + .hexpand(true) + } + .padding(5, .vertical) + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard let tag = hint.PrimaryImageTag, + let itemId = hint.Id ?? hint.ItemId else { return } + Task { + guard let url = await client.imageURL( + itemId: itemId, imageType: .Primary, tag: tag, maxWidth: 160 + ) else { return } + let service = ImageService() + imageData = try? await service.loadImage(url: url) + } + } +}