better handling of connection errors

This commit is contained in:
2025-05-30 16:45:09 -07:00
parent 45f1f521e2
commit 8807d6e621
4 changed files with 129 additions and 37 deletions

View File

@@ -9,7 +9,7 @@ import Foundation
struct MediaItem: Codable struct MediaItem: Codable
{ {
let filename: String let filename: String?
let title: String? let title: String?
let id: Int let id: Int
@@ -109,40 +109,65 @@ struct API
.post() .post()
} }
public func events() async throws -> AsyncStream<Event> { public func events() async throws -> AsyncStream<StreamEvent> {
return AsyncStream { continuation in return AsyncStream { continuation in
let url = request() var websocketTask: URLSessionWebSocketTask = spawnWebsocketTask(with: continuation)
.path("/events")
.websocket()
let websocketTask = URLSession.shared.webSocketTask(with: url)
websocketTask.resume()
Task { Task {
do { while true {
let event = { (data: Data) in try await Task.sleep(for: .seconds(5))
try JSONDecoder().decode(Event.self, from: data)
}
while websocketTask.state == .running { websocketTask.sendPing { error in
switch try await websocketTask.receive() { if let error {
case .string(let string): print("Ping error: \(error). Trying to reconnect.")
let event = try event(string.data(using: .utf8)!) continuation.yield(.error(.websocketError(error)))
continuation.yield(event) websocketTask = spawnWebsocketTask(with: continuation)
case .data(let data): } else {
let event = try event(data) continuation.yield(.event(Event(type: .receivedWebsocketPong)))
continuation.yield(event)
default:
break
} }
} }
} catch {
print("Websocket Error: \(error)")
} }
} }
} }
} }
private func spawnWebsocketTask(
with continuation: AsyncStream<StreamEvent>.Continuation
) -> URLSessionWebSocketTask
{
let url = request()
.path("/events")
.websocket()
let websocketTask = URLSession.shared.webSocketTask(with: url)
websocketTask.resume()
Task {
do {
let event = { (data: Data) in
try JSONDecoder().decode(Event.self, from: data)
}
while websocketTask.state == .running {
switch try await websocketTask.receive() {
case .string(let string):
let event = try event(string.data(using: .utf8)!)
continuation.yield(.event(event))
case .data(let data):
let event = try event(data)
continuation.yield(.event(event))
default:
break
}
}
} catch {
print("Websocket Error: \(error)")
}
}
return websocketTask
}
private func request() -> RequestBuilder { private func request() -> RequestBuilder {
RequestBuilder(url: baseURL) RequestBuilder(url: baseURL)
} }
@@ -152,6 +177,12 @@ struct API
enum Error: Swift.Error enum Error: Swift.Error
{ {
case apiNotConfigured case apiNotConfigured
case websocketError(Swift.Error)
}
enum StreamEvent {
case event(Event)
case error(API.Error)
} }
struct Event: Decodable struct Event: Decodable
@@ -169,6 +200,9 @@ struct 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"
// Private UI events
case receivedWebsocketPong
} }
} }
} }

View File

@@ -73,8 +73,14 @@ extension ContentView
do { do {
if what.contains(.nowPlaying) { if what.contains(.nowPlaying) {
let nowPlaying = try await api.fetchNowPlayingInfo() let nowPlaying = try await api.fetchNowPlayingInfo()
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title ?? "??" if let nowPlayingItem = nowPlaying.playingItem, let title = nowPlayingItem.title {
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename ?? "??" model.nowPlayingViewModel.title = title
model.nowPlayingViewModel.subtitle = nowPlayingItem.filename ?? ""
} else {
model.nowPlayingViewModel.title = "(Not Playing)"
model.nowPlayingViewModel.subtitle = ""
}
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0 model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
model.playlistModel.isPlaying = !nowPlaying.isPaused model.playlistModel.isPlaying = !nowPlaying.isPaused
@@ -86,14 +92,17 @@ extension ContentView
PlaylistItem( PlaylistItem(
index: idx, index: idx,
id: String(mediaItem.id), id: String(mediaItem.id),
title: mediaItem.title ?? mediaItem.filename, title: mediaItem.title ?? mediaItem.filename ?? "<null>",
filename: mediaItem.filename, filename: mediaItem.filename ?? "<null>",
isCurrent: mediaItem.current ?? false isCurrent: mediaItem.current ?? false
) )
} }
} }
model.connectionError = nil
} catch { } catch {
print("Error refreshing content: \(error)") print("Error refreshing content: \(error)")
model.connectionError = error
} }
} }
@@ -101,9 +110,14 @@ extension ContentView
guard let api = model.api else { return } guard let api = model.api else { return }
do { do {
for await event in try await api.events() { for await streamEvent in try await api.events() {
print("Got event: \(event.type)") switch streamEvent {
await handle(event: event) case .event(let event):
model.connectionError = nil
await handle(event: event)
case .error(let error):
model.connectionError = error
}
} }
} catch { } catch {
print("Events error: \(error)") print("Events error: \(error)")
@@ -123,6 +137,13 @@ extension ContentView
case .mpdUpdate: case .mpdUpdate:
await refresh([.playlist, .nowPlaying]) await refresh([.playlist, .nowPlaying])
case .receivedWebsocketPong:
// This means we're online.
if model.connectionError != nil {
model.connectionError = nil
await refresh([.playlist, .nowPlaying])
}
default: default:
break break
} }
@@ -134,6 +155,8 @@ class MainViewModel
{ {
var api = API.fromSettings() var api = API.fromSettings()
var connectionError: Error? = nil
var playlistModel = PlaylistViewModel() var playlistModel = PlaylistViewModel()
var nowPlayingViewModel = NowPlayingViewModel() var nowPlayingViewModel = NowPlayingViewModel()
var addMediaViewModel = AddMediaBarViewModel() var addMediaViewModel = AddMediaBarViewModel()
@@ -164,12 +187,10 @@ struct MainView: View
VStack { VStack {
NowPlayingView(model: model.nowPlayingViewModel) NowPlayingView(model: model.nowPlayingViewModel)
.padding() .padding()
.disabled(showConfigurationDialog) .disabled(showConfigurationDialog || model.connectionError != nil)
if showConfigurationDialog { if showConfigurationDialog {
Spacer() ContentPlaceholderView {
ContentUnavailableView {
Image(systemName: "server.rack") Image(systemName: "server.rack")
Text(.notConfigured) Text(.notConfigured)
} actions: { } actions: {
@@ -179,8 +200,11 @@ struct MainView: View
Text(.settings) Text(.settings)
} }
} }
} else if model.connectionError != nil {
Spacer() ContentPlaceholderView {
Image(systemName: "exclamationmark.triangle.fill")
Text(.connectionError)
}
} else { } else {
PlaylistView(model: model.playlistModel) PlaylistView(model: model.playlistModel)
.frame(maxWidth: 640.0) .frame(maxWidth: 640.0)
@@ -199,3 +223,26 @@ struct MainView: View
} }
} }
struct ContentPlaceholderView<Label, Actions>: View
where Label: View, Actions: View
{
let label: Label
let actions: Actions
init(@ViewBuilder label: () -> Label, @ViewBuilder actions: () -> Actions = { EmptyView() }) {
self.label = label()
self.actions = actions()
}
var body: some View {
Spacer()
ContentUnavailableView {
label
.imageScale(.large)
} actions: { actions }
Spacer()
}
}

View File

@@ -31,6 +31,16 @@
} }
} }
}, },
"CONNECTION_ERROR" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connection Error"
}
}
}
},
"DONE" : { "DONE" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@@ -21,4 +21,5 @@ extension LocalizedStringKey
static let configuration = LocalizedStringKey("CONFIGURATION") static let configuration = LocalizedStringKey("CONFIGURATION")
static let validating = LocalizedStringKey("VALIDATING") static let validating = LocalizedStringKey("VALIDATING")
static let general = LocalizedStringKey("GENERAL") static let general = LocalizedStringKey("GENERAL")
static let connectionError = LocalizedStringKey("CONNECTION_ERROR")
} }