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

View File

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

View File

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