From 0e7305baa450312f107b250cd8ebd10067fc7770 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 11 Jun 2025 21:16:59 -0700 Subject: [PATCH] implements youtube search --- QueueCube/Backend/API.swift | 22 +++ QueueCube/Localizable/Localizable.xcstrings | 20 +++ QueueCube/Localizable/Strings.swift | 2 + QueueCube/Views/AddMediaView.swift | 176 +++++++++++++++++++- 4 files changed, 219 insertions(+), 1 deletion(-) diff --git a/QueueCube/Backend/API.swift b/QueueCube/Backend/API.swift index e4804f1..be6a54a 100644 --- a/QueueCube/Backend/API.swift +++ b/QueueCube/Backend/API.swift @@ -31,6 +31,22 @@ struct MediaItem: Codable } } +struct SearchResultItem: Codable +{ + var type: String + var title: String + var author: String + var mediaUrl: String + var thumbnailUrl: String +} + +struct FetchResult: Codable +{ + let success: Bool + let results: T? + let error: String? +} + struct NowPlayingInfo: Codable { let playingItem: MediaItem? @@ -130,6 +146,12 @@ struct API .post() } + public func search(query: String) async throws -> FetchResult<[SearchResultItem]> { + try await request() + .pathString("/search?q=\(query.uriEncoded())") + .json() + } + public func events() async throws -> AsyncStream { return AsyncStream { continuation in var websocketTask: URLSessionWebSocketTask = spawnWebsocketTask(with: continuation) diff --git a/QueueCube/Localizable/Localizable.xcstrings b/QueueCube/Localizable/Localizable.xcstrings index df780dc..31ceec3 100644 --- a/QueueCube/Localizable/Localizable.xcstrings +++ b/QueueCube/Localizable/Localizable.xcstrings @@ -145,6 +145,16 @@ } } }, + "NO_RESULTS_FOUND" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Results Found" + } + } + } + }, "NO_SERVERS_CONFIGURED" : { "localizations" : { "en" : { @@ -198,6 +208,16 @@ } } }, + "SEARCHING_" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Searching…" + } + } + } + }, "SERVER_IS_ONLINE" : { "localizations" : { "en" : { diff --git a/QueueCube/Localizable/Strings.swift b/QueueCube/Localizable/Strings.swift index c7de132..22709d7 100644 --- a/QueueCube/Localizable/Strings.swift +++ b/QueueCube/Localizable/Strings.swift @@ -35,4 +35,6 @@ extension LocalizedStringKey static let favoritesEmpty = LocalizedStringKey("FAVORITES_IS_EMPTY") static let addMedia = LocalizedStringKey("ADD_MEDIA") static let searchForMedia = LocalizedStringKey("SEARCH_FOR_MEDIA") + static let searching = LocalizedStringKey("SEARCHING_") + static let noResultsFound = LocalizedStringKey("NO_RESULTS_FOUND") } diff --git a/QueueCube/Views/AddMediaView.swift b/QueueCube/Views/AddMediaView.swift index d73f3c1..2bc1f6a 100644 --- a/QueueCube/Views/AddMediaView.swift +++ b/QueueCube/Views/AddMediaView.swift @@ -96,15 +96,189 @@ struct AddMediaView: View struct SearchMediaView: View { @Binding var model: AddMediaView.ViewModel + @State private var searchModel = SearchModel() + @State private var searchText = "" + @FocusState private var searchFieldFocused: Bool var body: some View { - HStack { + VStack(spacing: 0) { + // Search field + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField(.searchForMedia, text: $searchText) + .focused($searchFieldFocused) + .onSubmit { + performSearch() + } + + if !searchText.isEmpty { + Button { + searchText = "" + searchModel.displayedResults = [] + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + } + .padding() + .background(Color(.systemGray6)) + if searchModel.isLoading { + VStack { + Spacer() + ProgressView(.searching) + .progressViewStyle(CircularProgressViewStyle()) + Spacer() + } + } else if searchModel.displayedResults.isEmpty && !searchText.isEmpty && searchModel.lastSearchedQuery == searchText { + VStack { + Spacer() + Text(.noResultsFound) + .foregroundColor(.secondary) + Spacer() + } + } else { + // Results list + List(searchModel.displayedResults, id: \.mediaUrl) { item in + SearchResultRow(item: item) { + model.onAdd(item.mediaUrl) + } + } + .listStyle(PlainListStyle()) + } } .navigationTitle(.searchForMedia) .presentationBackground(.regularMaterial) .onAppear { model.activeDetent = AddMediaView.ViewModel.Detent.expanded.value + searchFieldFocused = true + } + } + + private func performSearch() { + guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + searchModel.performSearch(query: searchText) + } +} + +struct SearchResultRow: View +{ + let item: SearchResultItem + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + // Thumbnail + AsyncImage(url: URL(string: item.thumbnailUrl)) { phase in + switch phase { + case .empty: + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .frame(width: 80, height: 60) + .overlay { + ProgressView() + .scaleEffect(0.8) + } + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8)) + case .failure(_): + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .frame(width: 80, height: 60) + .overlay { + Image(systemName: "photo") + .foregroundColor(.secondary) + } + @unknown default: + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .frame(width: 80, height: 60) + } + } + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.headline) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + .lineLimit(2) + + Text(item.author) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + + Text(item.type.capitalized) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color(.systemGray6)) + .clipShape(Capsule()) + } + + Spacer() + + Image(systemName: "plus.circle.fill") + .foregroundColor(.accentColor) + .font(.title2) + } + .padding(.vertical, 8) + } + .buttonStyle(PlainButtonStyle()) + } +} + +extension SearchMediaView +{ + // MARK: - Types + + @Observable + class SearchModel + { + var displayedResults: [SearchResultItem] = [] + var isLoading: Bool = false + var lastSearchedQuery: String? = nil + + func performSearch(query: String) { + guard let api = Settings.fromDefaults().selectedServer?.api else { return } + + isLoading = true + lastSearchedQuery = query + + Task { + do { + let fetchResult = try await api.search(query: query) + if let results = fetchResult.results { + await MainActor.run { + self.displayedResults = results + .map { item in + // Convert relative thumbnail urls to absolute for loading by AsyncImage + var copy = item + copy.thumbnailUrl = api.baseURL.absoluteString + .replacingOccurrences(of: "/api", with: "") + item.thumbnailUrl // xxx: ugh... + return copy + } + + self.isLoading = false + } + } + } catch { + await MainActor.run { + self.displayedResults = [] + self.isLoading = false + } + } + } } } }