Files
QueueCube/ios/QueueCube/Views/AddMediaView.swift
T
buzzert 71142e99fc
TestFlight / testflight (push) Successful in 1m24s
ios: fix media input sizing and playback progress
2026-07-19 00:22:04 -07:00

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