implements youtube search
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user