diff --git a/QueueCube/API.swift b/QueueCube/API.swift index bea4601..449d95a 100644 --- a/QueueCube/API.swift +++ b/QueueCube/API.swift @@ -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 { + public func events() async throws -> AsyncStream { 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.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 } } } diff --git a/QueueCube/ContentView.swift b/QueueCube/ContentView.swift index 4cc0b7c..f87686f 100644 --- a/QueueCube/ContentView.swift +++ b/QueueCube/ContentView.swift @@ -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 ?? "", + filename: mediaItem.filename ?? "", 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)") @@ -122,6 +136,13 @@ extension ContentView case .metadataUpdate: fallthrough 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: 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() + } +} diff --git a/QueueCube/Localizable/Localizable.xcstrings b/QueueCube/Localizable/Localizable.xcstrings index 6719077..ff6423f 100644 --- a/QueueCube/Localizable/Localizable.xcstrings +++ b/QueueCube/Localizable/Localizable.xcstrings @@ -31,6 +31,16 @@ } } }, + "CONNECTION_ERROR" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection Error" + } + } + } + }, "DONE" : { "localizations" : { "en" : { diff --git a/QueueCube/Localizable/Strings.swift b/QueueCube/Localizable/Strings.swift index ce9c4f4..a239696 100644 --- a/QueueCube/Localizable/Strings.swift +++ b/QueueCube/Localizable/Strings.swift @@ -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") }