implements youtube search

This commit is contained in:
2025-06-11 21:16:59 -07:00
parent 937a061cdd
commit 0e7305baa4
4 changed files with 219 additions and 1 deletions

View File

@@ -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<T: Codable>: 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<StreamEvent> {
return AsyncStream { continuation in
var websocketTask: URLSessionWebSocketTask = spawnWebsocketTask(with: continuation)

View File

@@ -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" : {

View File

@@ -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")
}

View File

@@ -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
}
}
}
}
}
}