Compare commits
3 Commits
58a43a617e
...
3aa819eccc
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aa819eccc | |||
| 718518c3f2 | |||
| f3053c1db1 |
@@ -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 */;
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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 {
|
||||||
.autocapitalization(.none)
|
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
|
||||||
.autocorrectionDisabled()
|
.autocapitalization(.none)
|
||||||
.focused($fieldFocused)
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
ios/QueueCube/Views/AutofocusingTextField.swift
Normal file
69
ios/QueueCube/Views/AutofocusingTextField.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user