better handling of connection errors
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONNECTION_ERROR" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Connection Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DONE" : {
|
"DONE" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user