ios: add error handling
This commit is contained in:
@@ -16,6 +16,7 @@ struct MediaItem: Codable
|
|||||||
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 ?? displayFilename ?? "item \(id)"
|
metadata?.title ?? title ?? displayFilename ?? "item \(id)"
|
||||||
@@ -280,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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ struct AutofocusingTextField: UIViewRepresentable
|
|||||||
tf.delegate = context.coordinator
|
tf.delegate = context.coordinator
|
||||||
tf.returnKeyType = .done
|
tf.returnKeyType = .done
|
||||||
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||||
|
tf.autocorrectionType = .no
|
||||||
|
tf.autocapitalizationType = .none
|
||||||
return tf
|
return tf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +162,8 @@ 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:
|
||||||
|
|||||||
@@ -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,6 +57,7 @@ class MediaListViewModel
|
|||||||
struct MediaListView: View
|
struct MediaListView: View
|
||||||
{
|
{
|
||||||
@Binding var model: MediaListViewModel
|
@Binding var model: MediaListViewModel
|
||||||
|
@State private var errorAlertItem: MediaListItem? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -70,20 +74,28 @@ struct MediaListView: View
|
|||||||
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)
|
} else {
|
||||||
case .favorites:
|
switch model.mode {
|
||||||
model.onPlay(item)
|
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 +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 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)
|
||||||
|
|
||||||
@@ -166,6 +195,18 @@ struct MediaItemCell: View
|
|||||||
.padding([.top, .bottom], 4.0)
|
.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
|
// MARK: - Types
|
||||||
|
|
||||||
enum State {
|
enum State {
|
||||||
@@ -174,4 +215,3 @@ struct MediaItemCell: View
|
|||||||
case paused
|
case paused
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user