3 Commits

Author SHA1 Message Date
3aa819eccc ios: Fixes for add media view
- Adds paste button
- Fix autofocus behavior (using UIKit)
- Remove pointless sheet detents
2025-11-15 17:42:00 -08:00
718518c3f2 ios: tighten fonts / list styles 2025-11-15 17:13:11 -08:00
f3053c1db1 ios: fix backgrounding error 2025-11-15 16:15:53 -08:00
8 changed files with 107 additions and 49 deletions

View File

@@ -14,6 +14,7 @@
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = { CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
App/Entitlements.plist,
App/Info.plist, App/Info.plist,
); );
target = CD4E9B962D7691C20066FC17 /* QueueCube */; target = CD4E9B962D7691C20066FC17 /* QueueCube */;

View File

@@ -18,7 +18,16 @@ struct MediaItem: Codable
let metadata: Metadata? let metadata: Metadata?
var displayTitle: String { var displayTitle: String {
metadata?.title ?? title ?? filename ?? "item \(id)" metadata?.title ?? title ?? displayFilename ?? "item \(id)"
}
private var displayFilename: String? {
guard let filename else { return nil }
if let url = URL(string: filename) {
return url.lastPathComponent
}
return filename
} }
// MARK: - Types // MARK: - Types
@@ -234,8 +243,6 @@ actor API
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) { private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
print("Websocket Error: \(error)") print("Websocket Error: \(error)")
let nsError = error as NSError
// Always notify observers of WebSocket errors so reconnection can happen // Always notify observers of WebSocket errors so reconnection can happen
// The UI layer can decide whether to show the error to the user // The UI layer can decide whether to show the error to the user
continuation.yield(.error(.websocketError(error))) continuation.yield(.error(.websocketError(error)))

View File

@@ -10,17 +10,23 @@ import SwiftUI
struct AddMediaView: View struct AddMediaView: View
{ {
@Binding var model: ViewModel @Binding var model: ViewModel
@FocusState var fieldFocused: Bool
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
// Add URL // Add URL
Section { Section {
TextField(.addAnyURL, text: $model.fieldContents) HStack {
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
.autocapitalization(.none) .autocapitalization(.none)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused($fieldFocused)
PasteButton(payloadType: String.self) { payload in
guard let contents = payload.first else { return }
model.fieldContents = contents
}
.labelStyle(.iconOnly)
}
} }
if model.supportsSearch { if model.supportsSearch {
@@ -35,8 +41,6 @@ struct AddMediaView: View
} }
} }
} }
.task { fieldFocused = true }
.onAppear { model.activeDetent = ViewModel.Detent.collapsed.value }
.navigationTitle(.addMedia) .navigationTitle(.addMedia)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -72,21 +76,6 @@ struct AddMediaView: View
var onSearch: () -> Void = { } var onSearch: () -> Void = { }
var supportsSearch: Bool = true var supportsSearch: Bool = true
var activeDetent: PresentationDetent = Detent.collapsed.value
enum Detent: CaseIterable
{
case collapsed
case expanded
var value: PresentationDetent {
switch self {
case .collapsed: .height(320.0)
case .expanded: .large
}
}
}
fileprivate func addButtonTapped() { fileprivate func addButtonTapped() {
onAdd(fieldContents) onAdd(fieldContents)
} }
@@ -107,11 +96,8 @@ struct SearchMediaView: View
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundColor(.secondary) .foregroundColor(.secondary)
TextField(.searchForMedia, text: $searchText) AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
.focused($searchFieldFocused) .focused($searchFieldFocused)
.onSubmit {
performSearch()
}
if !searchText.isEmpty { if !searchText.isEmpty {
Button { Button {
@@ -153,7 +139,6 @@ struct SearchMediaView: View
.navigationTitle(.searchForMedia) .navigationTitle(.searchForMedia)
.presentationBackground(.regularMaterial) .presentationBackground(.regularMaterial)
.onAppear { .onAppear {
model.activeDetent = AddMediaView.ViewModel.Detent.expanded.value
searchFieldFocused = true searchFieldFocused = true
} }
} }

View File

@@ -0,0 +1,69 @@
//
// AutofocusingTextField.swift
// QueueCube
//
// Created by James Magahern on 11/15/25.
//
import SwiftUI
import UIKit
/// Stupid: it appears to be impossible to make it so SwiftUI's `.focused(_:)` modifier takes place during the
/// presentation of a sheet, so this needs to exist just to make sure it's made first responder as soon as it moves to
/// the view hierarchy.
struct AutofocusingTextField: UIViewRepresentable
{
let placeholder: String
@Binding var text: String
var onSubmit: () -> Void = {}
init(_ placeholder: String, text: Binding<String>, onSubmit: @escaping () -> Void = {}) {
self.placeholder = placeholder
self._text = text
self.onSubmit = onSubmit
}
func makeUIView(context: Context) -> UITextField {
let tf = FirstResponderTextField()
tf.placeholder = placeholder
tf.delegate = context.coordinator
tf.returnKeyType = .done
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
return tf
}
func updateUIView(_ uiView: UITextField, context: Context) {
if uiView.text != text {
uiView.text = text
}
context.coordinator.parent = self // keep latest onSubmit/text binding
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
final class Coordinator: NSObject, UITextFieldDelegate {
var parent: AutofocusingTextField
init(parent: AutofocusingTextField) {
self.parent = parent
}
func textFieldDidChangeSelection(_ textField: UITextField) {
parent.text = textField.text ?? ""
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
parent.onSubmit()
return true
}
}
}
final class FirstResponderTextField: UITextField {
override func didMoveToSuperview() {
super.didMoveToSuperview()
becomeFirstResponder()
}
}

View File

@@ -29,10 +29,6 @@ struct ContentView: View
.sheet(isPresented: $model.isAddMediaSheetPresented) { .sheet(isPresented: $model.isAddMediaSheetPresented) {
AddMediaView(model: $model.addMediaViewModel) AddMediaView(model: $model.addMediaViewModel)
.presentationBackground(.regularMaterial) .presentationBackground(.regularMaterial)
.presentationDetents(
Set(AddMediaView.ViewModel.Detent.allCases.map { $0.value }),
selection: $model.addMediaViewModel.activeDetent
)
} }
.sheet(isPresented: $model.isEditSheetPresented) { .sheet(isPresented: $model.isEditSheetPresented) {
EditItemView(model: $model.editMediaViewModel) EditItemView(model: $model.editMediaViewModel)
@@ -121,9 +117,15 @@ extension ContentView
await clearConnectionErrorIfNecessary() await clearConnectionErrorIfNecessary()
await handle(event: event) await handle(event: event)
case .error(let error): case .error(let error):
// Ignore if we're in the bg
guard scenePhase == .active else { break }
// Check if this is a backgrounding error (connection abort) // Check if this is a backgrounding error (connection abort)
let nsError = error as NSError var isBackgroundingError = false
let isBackgroundingError = nsError.code == 53 if case let .websocketError(wsError) = error {
let nsError = wsError as NSError
isBackgroundingError = nsError.code == 53
}
// Only show connection error to user if it's not a backgrounding error // Only show connection error to user if it's not a backgrounding error
if !isBackgroundingError { if !isBackgroundingError {

View File

@@ -325,18 +325,9 @@ struct ServerSelectionToolbarModifier: ViewModifier
} }
} }
} }
#if false
// TODO
Section {
Button(.addServer) {
}
}
#endif
} label: { } label: {
Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down") Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down")
.labelStyle(.titleAndIcon) .labelStyle(.titleOnly)
} }
.buttonBorderShape(.capsule) .buttonBorderShape(.capsule)
.buttonStyle(.bordered) .buttonStyle(.bordered)

View File

@@ -41,16 +41,16 @@ struct NowPlayingView: View
VStack { VStack {
if let title = model.title { if let title = model.title {
Text(title) Text(title)
.font(.title2) .font(.title3)
.lineLimit(1) .lineLimit(1)
.bold() .bold()
} }
if let subtitle = model.subtitle { if let subtitle = model.subtitle {
Text(subtitle) Text(subtitle)
.font(.title3) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(2)
} }
if nothingQueued { if nothingQueued {

View File

@@ -150,10 +150,13 @@ struct MediaItemCell: View
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(title) Text(title)
.bold()
.font(.subheadline)
.tint(.primary) .tint(.primary)
.lineLimit(1) .lineLimit(1)
Text(subtitle) Text(subtitle)
.font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
} }