Compare commits
10 Commits
58a43a617e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d4eae9676 | |||
| 848c4c2b55 | |||
| a6ca763730 | |||
| 0916de60f3 | |||
| cfc6e6c411 | |||
| bc54735d1f | |||
| 04d23bec1e | |||
| 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 */;
|
||||||
@@ -268,7 +269,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -283,7 +284,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.5.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -304,7 +305,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -319,7 +320,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.5.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -12,13 +12,23 @@ struct MediaItem: Codable
|
|||||||
let filename: String?
|
let filename: String?
|
||||||
let title: String?
|
let title: String?
|
||||||
let id: Int
|
let id: Int
|
||||||
|
|
||||||
let current: Bool?
|
let current: Bool?
|
||||||
let playing: Bool?
|
let playing: Bool?
|
||||||
let metadata: Metadata?
|
let metadata: Metadata?
|
||||||
|
let playbackError: String?
|
||||||
|
|
||||||
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 +244,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)))
|
||||||
@@ -273,6 +281,7 @@ actor API
|
|||||||
case favoritesUpdate = "favorites_update"
|
case favoritesUpdate = "favorites_update"
|
||||||
case metadataUpdate = "metadata_update"
|
case metadataUpdate = "metadata_update"
|
||||||
case mpdUpdate = "mpd_update"
|
case mpdUpdate = "mpd_update"
|
||||||
|
case playbackError = "playback_error"
|
||||||
|
|
||||||
// Private UI events
|
// Private UI events
|
||||||
case receivedWebsocketPong
|
case receivedWebsocketPong
|
||||||
|
|||||||
@@ -104,6 +104,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DELETE" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DISCOVERED" : {
|
"DISCOVERED" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -247,6 +257,16 @@
|
|||||||
},
|
},
|
||||||
"Nothing here yet." : {
|
"Nothing here yet." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"PLAYBACK_ERROR" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Playback Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"PLAYLIST" : {
|
"PLAYLIST" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -360,6 +380,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Unknown error" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Unknown Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"URL" : {
|
"URL" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@@ -46,4 +46,6 @@ extension LocalizedStringKey
|
|||||||
static let notPlaying = LocalizedStringKey("NOT_PLAYING")
|
static let notPlaying = LocalizedStringKey("NOT_PLAYING")
|
||||||
static let url = LocalizedStringKey("URL")
|
static let url = LocalizedStringKey("URL")
|
||||||
static let title = LocalizedStringKey("TITLE")
|
static let title = LocalizedStringKey("TITLE")
|
||||||
|
static let playbackError = LocalizedStringKey("PLAYBACK_ERROR")
|
||||||
|
static let delete = LocalizedStringKey("DELETE")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
ios/QueueCube/Views/AutofocusingTextField.swift
Normal file
71
ios/QueueCube/Views/AutofocusingTextField.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
tf.autocorrectionType = .no
|
||||||
|
tf.autocapitalizationType = .none
|
||||||
|
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)
|
||||||
@@ -91,7 +87,8 @@ extension ContentView
|
|||||||
title: mediaItem.displayTitle,
|
title: mediaItem.displayTitle,
|
||||||
filename: mediaItem.filename ?? "<null>",
|
filename: mediaItem.filename ?? "<null>",
|
||||||
index: idx,
|
index: idx,
|
||||||
isCurrent: mediaItem.current ?? false
|
isCurrent: mediaItem.current ?? false,
|
||||||
|
playbackError: mediaItem.playbackError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +101,8 @@ extension ContentView
|
|||||||
id: String(mediaItem.id),
|
id: String(mediaItem.id),
|
||||||
title: mediaItem.displayTitle,
|
title: mediaItem.displayTitle,
|
||||||
filename: mediaItem.filename ?? "<null>",
|
filename: mediaItem.filename ?? "<null>",
|
||||||
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename
|
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename,
|
||||||
|
playbackError: mediaItem.playbackError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,9 +119,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 {
|
||||||
@@ -158,9 +162,10 @@ extension ContentView
|
|||||||
|
|
||||||
case .websocketReconnected: fallthrough
|
case .websocketReconnected: fallthrough
|
||||||
case .metadataUpdate: fallthrough
|
case .metadataUpdate: fallthrough
|
||||||
case .mpdUpdate:
|
case .mpdUpdate: fallthrough
|
||||||
|
case .playbackError:
|
||||||
await refresh([.playlist, .nowPlaying, .favorites])
|
await refresh([.playlist, .nowPlaying, .favorites])
|
||||||
|
|
||||||
case .receivedWebsocketPong:
|
case .receivedWebsocketPong:
|
||||||
// This means we're online.
|
// This means we're online.
|
||||||
await clearConnectionErrorIfNecessary()
|
await clearConnectionErrorIfNecessary()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -14,17 +14,19 @@ struct MediaListItem: Identifiable
|
|||||||
let filename: String
|
let filename: String
|
||||||
let index: Int?
|
let index: Int?
|
||||||
let isCurrent: Bool
|
let isCurrent: Bool
|
||||||
|
let playbackError: String?
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
_id + filename // temporary: we get duplicate ids from the server sometimes...
|
_id + filename // temporary: we get duplicate ids from the server sometimes...
|
||||||
}
|
}
|
||||||
|
|
||||||
init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false) {
|
init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false, playbackError: String? = nil) {
|
||||||
self._id = id
|
self._id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.filename = filename
|
self.filename = filename
|
||||||
self.index = index
|
self.index = index
|
||||||
self.isCurrent = isCurrent
|
self.isCurrent = isCurrent
|
||||||
|
self.playbackError = playbackError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ class MediaListViewModel
|
|||||||
var onQueue: (MediaListItem) -> Void = { _ in }
|
var onQueue: (MediaListItem) -> Void = { _ in }
|
||||||
var onEdit: (MediaListItem) -> Void = { _ in }
|
var onEdit: (MediaListItem) -> Void = { _ in }
|
||||||
var onFavorite: (MediaListItem) -> Void = { _ in }
|
var onFavorite: (MediaListItem) -> Void = { _ in }
|
||||||
|
var onDelete: (MediaListItem) -> Void = { _ in }
|
||||||
|
|
||||||
init(mode: MediaListMode) {
|
init(mode: MediaListMode) {
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -54,7 +57,9 @@ class MediaListViewModel
|
|||||||
struct MediaListView: View
|
struct MediaListView: View
|
||||||
{
|
{
|
||||||
@Binding var model: MediaListViewModel
|
@Binding var model: MediaListViewModel
|
||||||
|
@State private var errorAlertItem: MediaListItem? = nil
|
||||||
|
@State private var isShowingErrorAlert: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
if model.items.isEmpty {
|
if model.items.isEmpty {
|
||||||
@@ -68,22 +73,31 @@ struct MediaListView: View
|
|||||||
List($model.items, editActions: .delete) { item in
|
List($model.items, editActions: .delete) { item in
|
||||||
let item = item.wrappedValue
|
let item = item.wrappedValue
|
||||||
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
|
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
switch model.mode {
|
if let _ = item.playbackError {
|
||||||
case .playlist:
|
errorAlertItem = item
|
||||||
model.onSeek(item)
|
isShowingErrorAlert = true
|
||||||
case .favorites:
|
} else {
|
||||||
model.onPlay(item)
|
switch model.mode {
|
||||||
|
case .playlist:
|
||||||
|
model.onSeek(item)
|
||||||
|
case .favorites:
|
||||||
|
model.onPlay(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
MediaItemCell(
|
MediaItemCell(
|
||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.filename,
|
subtitle: item.filename,
|
||||||
state: state
|
state: state,
|
||||||
|
playbackError: item.playbackError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil)
|
.listRowBackground(
|
||||||
|
item.playbackError != nil ? Color.red.opacity(0.15) :
|
||||||
|
(model.mode == .playlist && state != .queued ? Color.accentColor.opacity(0.10) : nil)
|
||||||
|
)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(.copyTitle) {
|
Button(.copyTitle) {
|
||||||
UIPasteboard.general.string = item.title
|
UIPasteboard.general.string = item.title
|
||||||
@@ -123,6 +137,20 @@ struct MediaListView: View
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert(.playbackError, isPresented: $isShowingErrorAlert, presenting: errorAlertItem) { item in
|
||||||
|
Button(.cancel, role: .cancel) {
|
||||||
|
errorAlertItem = nil
|
||||||
|
isShowingErrorAlert = false
|
||||||
|
}
|
||||||
|
Button(.delete, role: .destructive) {
|
||||||
|
model.items.removeAll { $0.id == item.id }
|
||||||
|
model.onDelete(item)
|
||||||
|
errorAlertItem = nil
|
||||||
|
isShowingErrorAlert = false
|
||||||
|
}
|
||||||
|
} message: { item in
|
||||||
|
Text(item.playbackError ?? "Unknown error")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,26 +162,24 @@ struct MediaItemCell: View
|
|||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
let state: State
|
let state: State
|
||||||
|
let playbackError: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let icon: String = switch state {
|
|
||||||
case .queued: "play.fill"
|
|
||||||
case .playing: "speaker.wave.3.fill"
|
|
||||||
case .paused: "speaker.fill"
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: icon)
|
Image(systemName: iconName)
|
||||||
.tint(Color.primary)
|
.tint(playbackError == nil ? Color.primary : Color.orange)
|
||||||
.frame(width: 15.0)
|
.frame(width: 15.0)
|
||||||
.padding(.trailing, 10.0)
|
.padding(.trailing, 10.0)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -161,6 +187,19 @@ struct MediaItemCell: View
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding([.top, .bottom], 4.0)
|
.padding([.top, .bottom], 4.0)
|
||||||
|
.frame(minHeight: 44.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
if playbackError != nil {
|
||||||
|
return "exclamationmark.triangle.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .queued: return "play.fill"
|
||||||
|
case .playing: return "speaker.wave.3.fill"
|
||||||
|
case .paused: return "speaker.fill"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Types
|
// MARK: - Types
|
||||||
@@ -171,4 +210,3 @@ struct MediaItemCell: View
|
|||||||
case paused
|
case paused
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ enum UserEvent {
|
|||||||
FavoritesUpdate = "favorites_update",
|
FavoritesUpdate = "favorites_update",
|
||||||
MetadataUpdate = "metadata_update",
|
MetadataUpdate = "metadata_update",
|
||||||
MPDUpdate = "mpd_update",
|
MPDUpdate = "mpd_update",
|
||||||
|
PlaybackError = "playback_error",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Features {
|
export interface Features {
|
||||||
@@ -57,6 +58,9 @@ export class MediaPlayer {
|
|||||||
private dataBuffer: string = '';
|
private dataBuffer: string = '';
|
||||||
private metadata: Map<string, LinkMetadata> = new Map();
|
private metadata: Map<string, LinkMetadata> = new Map();
|
||||||
private bonjourInstance: Bonjour | null = null;
|
private bonjourInstance: Bonjour | null = null;
|
||||||
|
private playbackErrors: Map<string, string> = new Map();
|
||||||
|
private currentFile: string | null = null;
|
||||||
|
private lastLoadCandidate: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.socket = this.tryRespawnPlayerProcess();
|
this.socket = this.tryRespawnPlayerProcess();
|
||||||
@@ -152,7 +156,8 @@ export class MediaPlayer {
|
|||||||
const playlist = response.data as PlaylistItem[];
|
const playlist = response.data as PlaylistItem[];
|
||||||
return playlist.map((item: PlaylistItem) => ({
|
return playlist.map((item: PlaylistItem) => ({
|
||||||
...item,
|
...item,
|
||||||
metadata: this.metadata.get(item.filename) || {}
|
metadata: this.metadata.get(item.filename) || {},
|
||||||
|
playbackError: this.playbackErrors.get(item.filename)
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -352,6 +357,8 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
||||||
|
this.lastLoadCandidate = url;
|
||||||
|
this.playbackErrors.delete(url);
|
||||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
||||||
|
|
||||||
if (fetchMetadata) {
|
if (fetchMetadata) {
|
||||||
@@ -470,6 +477,24 @@ export class MediaPlayer {
|
|||||||
this.pendingCommands.delete(response.request_id);
|
this.pendingCommands.delete(response.request_id);
|
||||||
}
|
}
|
||||||
} else if (response.event) {
|
} else if (response.event) {
|
||||||
|
if (response.event === "start-file") {
|
||||||
|
// Clear any previous error for the file that is starting
|
||||||
|
const file = response.file || this.lastLoadCandidate;
|
||||||
|
if (file) {
|
||||||
|
this.currentFile = file;
|
||||||
|
this.playbackErrors.delete(file);
|
||||||
|
}
|
||||||
|
} else if (response.event === "end-file" && response.reason === "error") {
|
||||||
|
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
|
||||||
|
const errorMessage = response.error || response["file-error"] || "Unknown playback error";
|
||||||
|
|
||||||
|
this.playbackErrors.set(file, errorMessage);
|
||||||
|
this.handleEvent(UserEvent.PlaybackError, {
|
||||||
|
filename: file,
|
||||||
|
error: errorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.handleEvent(UserEvent.MPDUpdate, response);
|
this.handleEvent(UserEvent.MPDUpdate, response);
|
||||||
} else {
|
} else {
|
||||||
console.log(response);
|
console.log(response);
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ export interface PlaylistItem {
|
|||||||
playing?: boolean;
|
playing?: boolean;
|
||||||
current?: boolean;
|
current?: boolean;
|
||||||
metadata?: LinkMetadata;
|
metadata?: LinkMetadata;
|
||||||
}
|
playbackError?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface PlaylistItem {
|
|||||||
id: number;
|
id: number;
|
||||||
playing: boolean | null;
|
playing: boolean | null;
|
||||||
metadata?: Metadata;
|
metadata?: Metadata;
|
||||||
|
playbackError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDisplayTitle = (item: PlaylistItem): string => {
|
export const getDisplayTitle = (item: PlaylistItem): string => {
|
||||||
@@ -62,6 +63,7 @@ export enum ServerEvent {
|
|||||||
FavoritesUpdate = "favorites_update",
|
FavoritesUpdate = "favorites_update",
|
||||||
MetadataUpdate = "metadata_update",
|
MetadataUpdate = "metadata_update",
|
||||||
MPDUpdate = "mpd_update",
|
MPDUpdate = "mpd_update",
|
||||||
|
PlaybackError = "playback_error",
|
||||||
ScreenShare = "screen_share",
|
ScreenShare = "screen_share",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ const App: React.FC = () => {
|
|||||||
case ServerEvent.NowPlayingUpdate:
|
case ServerEvent.NowPlayingUpdate:
|
||||||
case ServerEvent.MetadataUpdate:
|
case ServerEvent.MetadataUpdate:
|
||||||
case ServerEvent.MPDUpdate:
|
case ServerEvent.MPDUpdate:
|
||||||
|
case ServerEvent.PlaybackError:
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
||||||
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
|
import { FaPlay, FaVolumeUp, FaVolumeOff, FaExclamationTriangle } from 'react-icons/fa';
|
||||||
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
||||||
|
|
||||||
export enum PlayState {
|
export enum PlayState {
|
||||||
@@ -19,6 +19,7 @@ export interface SongRowProps {
|
|||||||
|
|
||||||
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
|
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showErrorDetails, setShowErrorDetails] = useState(false);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +39,7 @@ const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete
|
|||||||
}, [showDeleteConfirm]);
|
}, [showDeleteConfirm]);
|
||||||
|
|
||||||
const displayTitle = getDisplayTitle(song);
|
const displayTitle = getDisplayTitle(song);
|
||||||
|
const hasError = !!song.playbackError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(
|
<div className={classNames(
|
||||||
@@ -46,16 +48,46 @@ const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete
|
|||||||
"bg-black/30": playState === PlayState.NotPlaying,
|
"bg-black/30": playState === PlayState.NotPlaying,
|
||||||
})}>
|
})}>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<button
|
<div className="relative">
|
||||||
className="text-white/40 hover:text-white transition-colors px-3 py-1 rounded"
|
<button
|
||||||
onClick={onPlay}
|
className={classNames(
|
||||||
>
|
"transition-colors px-3 py-1 rounded",
|
||||||
{
|
hasError ? "text-amber-300 hover:text-amber-100" : "text-white/40 hover:text-white"
|
||||||
playState === PlayState.Playing ? <FaVolumeUp size={12} />
|
)}
|
||||||
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
|
onClick={() => {
|
||||||
: <FaPlay size={12} />
|
if (hasError) {
|
||||||
}
|
setShowErrorDetails((prev) => !prev);
|
||||||
</button>
|
} else {
|
||||||
|
onPlay();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (hasError) {
|
||||||
|
setShowErrorDetails(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (hasError) {
|
||||||
|
setShowErrorDetails(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={hasError ? song.playbackError : undefined}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
hasError ? <FaExclamationTriangle size={12} /> :
|
||||||
|
playState === PlayState.Playing ? <FaVolumeUp size={12} />
|
||||||
|
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
|
||||||
|
: <FaPlay size={12} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasError && showErrorDetails && (
|
||||||
|
<div className="absolute z-10 top-full left-0 mt-1 w-64 p-2 text-xs text-white bg-red-600/90 rounded shadow-lg">
|
||||||
|
<div className="font-semibold mb-1">Playback error</div>
|
||||||
|
<div className="break-words">{song.playbackError}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow min-w-0">
|
<div className="flex-grow min-w-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user