273 lines
9.3 KiB
Swift
273 lines
9.3 KiB
Swift
//
|
|
// AddMediaView.swift
|
|
// QueueCube
|
|
//
|
|
// Created by James Magahern on 6/11/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct AddMediaView: View
|
|
{
|
|
@Binding var model: ViewModel
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// Add URL
|
|
Section {
|
|
HStack {
|
|
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
|
|
.autocapitalization(.none)
|
|
.autocorrectionDisabled()
|
|
.frame(minWidth: 0.0, maxWidth: .infinity)
|
|
|
|
PasteButton(payloadType: String.self) { payload in
|
|
guard let contents = payload.first else { return }
|
|
model.fieldContents = contents
|
|
}
|
|
.labelStyle(.iconOnly)
|
|
.fixedSize()
|
|
}
|
|
}
|
|
|
|
if model.supportsSearch {
|
|
Section {
|
|
NavigationLink {
|
|
SearchMediaView(model: $model)
|
|
} label: {
|
|
Image(systemName: "magnifyingglass")
|
|
Button(.searchForMedia, action: model.onSearch)
|
|
}
|
|
.tint(.label)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(.addMedia)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
Button(.add, action: model.addButtonTapped)
|
|
.disabled(model.fieldContents.isEmpty)
|
|
.bold()
|
|
}
|
|
|
|
ToolbarItemGroup(placement: .topBarLeading) {
|
|
Button(.cancel, action: model.onCancel)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Types
|
|
|
|
enum Page: String, Identifiable
|
|
{
|
|
case addURL
|
|
case searchMedia
|
|
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
@Observable
|
|
class ViewModel
|
|
{
|
|
var fieldContents: String = ""
|
|
var onAdd: (String) -> Void = { _ in }
|
|
var onCancel: () -> Void = { }
|
|
var onSearch: () -> Void = { }
|
|
var supportsSearch: Bool = true
|
|
|
|
fileprivate func addButtonTapped() {
|
|
onAdd(fieldContents)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
VStack(spacing: 0) {
|
|
// Search field
|
|
HStack {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundColor(.secondary)
|
|
|
|
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
|
|
.focused($searchFieldFocused)
|
|
.frame(minWidth: 0.0, maxWidth: .infinity)
|
|
|
|
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 {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|