From cfc6e6c4114bdba4ac5ed6aca3c4d360229e6105 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 15 Nov 2025 18:16:03 -0800 Subject: [PATCH] ios: add error handling --- ios/QueueCube/Backend/API.swift | 4 +- .../Localizable/Localizable.xcstrings | 30 +++++++ ios/QueueCube/Localizable/Strings.swift | 2 + .../Views/AutofocusingTextField.swift | 2 + ios/QueueCube/Views/ContentView.swift | 11 ++- ios/QueueCube/Views/PlaylistView.swift | 80 ++++++++++++++----- 6 files changed, 104 insertions(+), 25 deletions(-) diff --git a/ios/QueueCube/Backend/API.swift b/ios/QueueCube/Backend/API.swift index c1ba4a8..743b295 100644 --- a/ios/QueueCube/Backend/API.swift +++ b/ios/QueueCube/Backend/API.swift @@ -12,10 +12,11 @@ struct MediaItem: Codable let filename: String? let title: String? let id: Int - + let current: Bool? let playing: Bool? let metadata: Metadata? + let playbackError: String? var displayTitle: String { metadata?.title ?? title ?? displayFilename ?? "item \(id)" @@ -280,6 +281,7 @@ actor API case favoritesUpdate = "favorites_update" case metadataUpdate = "metadata_update" case mpdUpdate = "mpd_update" + case playbackError = "playback_error" // Private UI events case receivedWebsocketPong diff --git a/ios/QueueCube/Localizable/Localizable.xcstrings b/ios/QueueCube/Localizable/Localizable.xcstrings index 87be220..52a151f 100644 --- a/ios/QueueCube/Localizable/Localizable.xcstrings +++ b/ios/QueueCube/Localizable/Localizable.xcstrings @@ -104,6 +104,16 @@ } } }, + "DELETE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" + } + } + } + }, "DISCOVERED" : { "localizations" : { "en" : { @@ -247,6 +257,16 @@ }, "Nothing here yet." : { + }, + "PLAYBACK_ERROR" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playback Error" + } + } + } }, "PLAYLIST" : { "localizations" : { @@ -360,6 +380,16 @@ } } }, + "Unknown error" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown Error" + } + } + } + }, "URL" : { "localizations" : { "en" : { diff --git a/ios/QueueCube/Localizable/Strings.swift b/ios/QueueCube/Localizable/Strings.swift index bc73ee7..811b501 100644 --- a/ios/QueueCube/Localizable/Strings.swift +++ b/ios/QueueCube/Localizable/Strings.swift @@ -46,4 +46,6 @@ extension LocalizedStringKey static let notPlaying = LocalizedStringKey("NOT_PLAYING") static let url = LocalizedStringKey("URL") static let title = LocalizedStringKey("TITLE") + static let playbackError = LocalizedStringKey("PLAYBACK_ERROR") + static let delete = LocalizedStringKey("DELETE") } diff --git a/ios/QueueCube/Views/AutofocusingTextField.swift b/ios/QueueCube/Views/AutofocusingTextField.swift index 5432c27..a62e0f9 100644 --- a/ios/QueueCube/Views/AutofocusingTextField.swift +++ b/ios/QueueCube/Views/AutofocusingTextField.swift @@ -29,6 +29,8 @@ struct AutofocusingTextField: UIViewRepresentable tf.delegate = context.coordinator tf.returnKeyType = .done tf.setContentHuggingPriority(.defaultHigh, for: .vertical) + tf.autocorrectionType = .no + tf.autocapitalizationType = .none return tf } diff --git a/ios/QueueCube/Views/ContentView.swift b/ios/QueueCube/Views/ContentView.swift index f5de372..87df5ac 100644 --- a/ios/QueueCube/Views/ContentView.swift +++ b/ios/QueueCube/Views/ContentView.swift @@ -87,7 +87,8 @@ extension ContentView title: mediaItem.displayTitle, filename: mediaItem.filename ?? "", index: idx, - isCurrent: mediaItem.current ?? false + isCurrent: mediaItem.current ?? false, + playbackError: mediaItem.playbackError ) } } @@ -100,7 +101,8 @@ extension ContentView id: String(mediaItem.id), title: mediaItem.displayTitle, filename: mediaItem.filename ?? "", - isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename + isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename, + playbackError: mediaItem.playbackError ) } } @@ -160,9 +162,10 @@ extension ContentView case .websocketReconnected: fallthrough case .metadataUpdate: fallthrough - case .mpdUpdate: + case .mpdUpdate: fallthrough + case .playbackError: await refresh([.playlist, .nowPlaying, .favorites]) - + case .receivedWebsocketPong: // This means we're online. await clearConnectionErrorIfNecessary() diff --git a/ios/QueueCube/Views/PlaylistView.swift b/ios/QueueCube/Views/PlaylistView.swift index b491b1e..310aab0 100644 --- a/ios/QueueCube/Views/PlaylistView.swift +++ b/ios/QueueCube/Views/PlaylistView.swift @@ -14,17 +14,19 @@ struct MediaListItem: Identifiable let filename: String let index: Int? let isCurrent: Bool + let playbackError: String? var id: String { _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.title = title self.filename = filename self.index = index self.isCurrent = isCurrent + self.playbackError = playbackError } } @@ -45,6 +47,7 @@ class MediaListViewModel var onQueue: (MediaListItem) -> Void = { _ in } var onEdit: (MediaListItem) -> Void = { _ in } var onFavorite: (MediaListItem) -> Void = { _ in } + var onDelete: (MediaListItem) -> Void = { _ in } init(mode: MediaListMode) { self.mode = mode @@ -54,7 +57,8 @@ class MediaListViewModel struct MediaListView: View { @Binding var model: MediaListViewModel - + @State private var errorAlertItem: MediaListItem? = nil + var body: some View { VStack { if model.items.isEmpty { @@ -68,22 +72,30 @@ struct MediaListView: View List($model.items, editActions: .delete) { item in let item = item.wrappedValue let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued - + Button { - switch model.mode { - case .playlist: - model.onSeek(item) - case .favorites: - model.onPlay(item) + if let _ = item.playbackError { + errorAlertItem = item + } else { + switch model.mode { + case .playlist: + model.onSeek(item) + case .favorites: + model.onPlay(item) + } } } label: { MediaItemCell( title: item.title, 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 { Button(.copyTitle) { UIPasteboard.general.string = item.title @@ -123,6 +135,28 @@ struct MediaListView: View } } } + .alert(.playbackError, isPresented: Binding(get: { + errorAlertItem != nil + }, set: { newValue in + if !newValue { errorAlertItem = nil } + }), actions: { + Button(.cancel, role: .cancel) { + errorAlertItem = nil + } + if let item = errorAlertItem { + Button(.delete, role: .destructive) { + model.items.removeAll { $0.id == item.id } + model.onDelete(item) + errorAlertItem = nil + } + } + }, message: { + if let message = errorAlertItem?.playbackError { + Text(message) + } else { + Text("Unknown error") + } + }) } } } @@ -134,17 +168,12 @@ struct MediaItemCell: View let title: String let subtitle: String let state: State - + let playbackError: String? + var body: some View { - let icon: String = switch state { - case .queued: "play.fill" - case .playing: "speaker.wave.3.fill" - case .paused: "speaker.fill" - } - HStack { - Image(systemName: icon) - .tint(Color.primary) + Image(systemName: iconName) + .tint(playbackError == nil ? Color.primary : Color.orange) .frame(width: 15.0) .padding(.trailing, 10.0) @@ -166,6 +195,18 @@ struct MediaItemCell: View .padding([.top, .bottom], 4.0) } + private var iconName: String { + if let playbackError { + 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 enum State { @@ -174,4 +215,3 @@ struct MediaItemCell: View case paused } } -