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
|
struct NowPlayingInfo: Codable
|
||||||
{
|
{
|
||||||
let playingItem: MediaItem?
|
let playingItem: MediaItem?
|
||||||
@@ -130,6 +146,12 @@ struct API
|
|||||||
.post()
|
.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> {
|
public func events() async throws -> AsyncStream<StreamEvent> {
|
||||||
return AsyncStream { continuation in
|
return AsyncStream { continuation in
|
||||||
var websocketTask: URLSessionWebSocketTask = spawnWebsocketTask(with: continuation)
|
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" : {
|
"NO_SERVERS_CONFIGURED" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -198,6 +208,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SEARCHING_" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Searching…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SERVER_IS_ONLINE" : {
|
"SERVER_IS_ONLINE" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@@ -35,4 +35,6 @@ extension LocalizedStringKey
|
|||||||
static let favoritesEmpty = LocalizedStringKey("FAVORITES_IS_EMPTY")
|
static let favoritesEmpty = LocalizedStringKey("FAVORITES_IS_EMPTY")
|
||||||
static let addMedia = LocalizedStringKey("ADD_MEDIA")
|
static let addMedia = LocalizedStringKey("ADD_MEDIA")
|
||||||
static let searchForMedia = LocalizedStringKey("SEARCH_FOR_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
|
struct SearchMediaView: View
|
||||||
{
|
{
|
||||||
@Binding var model: AddMediaView.ViewModel
|
@Binding var model: AddMediaView.ViewModel
|
||||||
|
@State private var searchModel = SearchModel()
|
||||||
|
@State private var searchText = ""
|
||||||
|
@FocusState private var searchFieldFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Search field
|
||||||
HStack {
|
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)
|
.navigationTitle(.searchForMedia)
|
||||||
.presentationBackground(.regularMaterial)
|
.presentationBackground(.regularMaterial)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
model.activeDetent = AddMediaView.ViewModel.Detent.expanded.value
|
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