better error handling and server switching

This commit is contained in:
2025-06-11 18:41:39 -07:00
parent afe985661a
commit bde29e7e98
9 changed files with 158 additions and 136 deletions

View File

@@ -177,6 +177,7 @@ struct API
} }
} catch { } catch {
print("Websocket Error: \(error)") print("Websocket Error: \(error)")
continuation.yield(.error(API.Error.websocketError(error)))
} }
} }

View File

@@ -104,6 +104,16 @@
} }
} }
}, },
"FAVORITES_IS_EMPTY" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Favorites is empty"
}
}
}
},
"FINDING_SERVERS" : { "FINDING_SERVERS" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -155,6 +165,16 @@
} }
} }
}, },
"PLAYLIST_IS_EMPTY" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Playlist is empty"
}
}
}
},
"SERVER_IS_ONLINE" : { "SERVER_IS_ONLINE" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@@ -31,4 +31,6 @@ extension LocalizedStringKey
static let discovered = LocalizedStringKey("DISCOVERED") static let discovered = LocalizedStringKey("DISCOVERED")
static let findingServers = LocalizedStringKey("FINDING_SERVERS") static let findingServers = LocalizedStringKey("FINDING_SERVERS")
static let noServersConfigured = LocalizedStringKey("NO_SERVERS_CONFIGURED") static let noServersConfigured = LocalizedStringKey("NO_SERVERS_CONFIGURED")
static let playlistEmpty = LocalizedStringKey("PLAYLIST_IS_EMPTY")
static let favoritesEmpty = LocalizedStringKey("FAVORITES_IS_EMPTY")
} }

View File

@@ -48,6 +48,7 @@ func contentPlaceholderView<Actions>(
Text(title) Text(title)
.foregroundStyle(.tint)
.bold() .bold()
Spacer() Spacer()

View File

@@ -9,74 +9,12 @@ import SwiftUI
struct ContentView: View struct ContentView: View
{ {
@State var model: MainViewModel @State var model = MainViewModel()
@State private var websocketRestartTrigger = 0
init() {
self.model = MainViewModel()
if let api = model.selectedServer?.api {
let nowPlayingModel = self.model.nowPlayingViewModel
nowPlayingModel.onPlayPause = { model in
Task { model.isPlaying ? try await api.pause() : try await api.play() }
}
nowPlayingModel.onNext = { _ in
Task { try await api.skip() }
}
nowPlayingModel.onPrev = { _ in
Task { try await api.previous() }
}
nowPlayingModel.onVolumeChange = { model in
Task { try await api.setVolume(model.volume) }
}
let playlistModel = model.playlistModel
playlistModel.onSeek = { item in
Task {
if let index = item.index {
try await api.skip(index)
}
}
}
playlistModel.onDelete = { item in
Task {
if let index = item.index {
try await api.delete(index: index)
}
}
}
let addMediaModel = model.addMediaViewModel
addMediaModel.onAdd = { [model] mediaURL in
let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !strippedURL.isEmpty {
addMediaModel.fieldContents = ""
Task {
switch model.selectedTab {
case .playlist:
try await api.add(mediaURL: strippedURL)
case .favorites:
try await api.addFavorite(mediaURL: strippedURL)
case .settings:
break
}
}
}
}
let favoritesModel = model.favoritesModel
favoritesModel.onPlay = { item in
Task { try await api.add(mediaURL: item.filename) }
}
favoritesModel.onDelete = { item in
// TODO: Implement favorites deletion when API supports it
print("Delete favorite: \(item.title)")
}
}
}
var body: some View { var body: some View {
MainView(model: model) MainView(model: $model)
.task { await watchWebsocket() } .task(id: websocketRestartTrigger) { await watchWebsocket() }
.task { await refresh([.nowPlaying, .playlist, .favorites]) } .task { await refresh([.nowPlaying, .playlist, .favorites]) }
.task { await watchForSettingsChanges() } .task { await watchForSettingsChanges() }
} }
@@ -138,8 +76,6 @@ extension ContentView
) )
} }
} }
model.connectionError = nil
} }
} }
@@ -196,7 +132,10 @@ extension ContentView
model.selectedServer = newSelectedServer model.selectedServer = newSelectedServer
// Reset view model to defaults // Reset view model to defaults
model.reset() await model.reset()
// Restart WebSocket connection for new server
websocketRestartTrigger += 1
await refresh([.playlist, .nowPlaying, .favorites]) await refresh([.playlist, .nowPlaying, .favorites])
} }

View File

@@ -33,20 +33,71 @@ class MainViewModel
init() { init() {
observePlaylistChanges() observePlaylistChanges()
configureViewModelCallbacks()
} }
func onAddButtonTapped() { func onAddButtonTapped() {
} }
func reset() { func reset() async {
refreshingFromAPIDepth = 1 await withModificationsViaAPI { _ in
playlistModel = MediaListViewModel(mode: .playlist) playlistModel = MediaListViewModel(mode: .playlist)
favoritesModel = MediaListViewModel(mode: .favorites) favoritesModel = MediaListViewModel(mode: .favorites)
nowPlayingViewModel = NowPlayingViewModel() nowPlayingViewModel = NowPlayingViewModel()
}
refreshingFromAPIDepth = 0 configureViewModelCallbacks()
}
func configureViewModelCallbacks() {
// Now Playing
nowPlayingViewModel.onPlayPause = apiCallback { model, api in
model.isPlaying ? try await api.pause() : try await api.play()
}
nowPlayingViewModel.onNext = apiCallback { _, api in
try await api.skip()
}
nowPlayingViewModel.onPrev = apiCallback { _, api in
try await api.previous()
}
nowPlayingViewModel.onVolumeChange = apiCallback { model, api in
try await api.setVolume(model.volume)
}
// Playlist
playlistModel.onSeek = apiCallback { item, api in
if let index = item.index {
try await api.skip(index)
}
}
// Favorites
favoritesModel.onPlay = apiCallback { item, api in
try await api.add(mediaURL: item.filename)
}
// Add Media
addMediaViewModel.onAdd = apiCallback { [weak self] mediaURL, api in
guard let self else { return }
let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !strippedURL.isEmpty {
addMediaViewModel.fieldContents = ""
switch selectedTab {
case .playlist:
try await api.add(mediaURL: strippedURL)
case .favorites:
try await api.addFavorite(mediaURL: strippedURL)
case .settings:
break
}
}
}
} }
func withModificationsViaAPI(_ modificationBlock: (API) async throws -> Void) async { func withModificationsViaAPI(_ modificationBlock: (API) async throws -> Void) async {
@@ -65,7 +116,15 @@ class MainViewModel
refreshingFromAPIDepth -= 1 refreshingFromAPIDepth -= 1
} }
func observePlaylistChanges() { private func apiCallback<T>(_ f: @escaping (T, API) async throws -> Void) -> (T) -> Void {
return { t in
Task {
await self.withModificationsViaAPI { try await f(t, $0) }
}
}
}
private func observePlaylistChanges() {
withObservationTracking { withObservationTracking {
_ = playlistModel.items _ = playlistModel.items
_ = favoritesModel.items _ = favoritesModel.items
@@ -111,27 +170,26 @@ class MainViewModel
struct MainView: View struct MainView: View
{ {
@State var model: MainViewModel @Binding var model: MainViewModel
@State var isSettingsVisible: Bool = false @State var isSettingsVisible: Bool = false
init(model: MainViewModel) { init(model: Binding<MainViewModel>) {
self.model = model self._model = model
// If no servers are configured, make Settings the default tab. // If no servers are configured, make Settings the default tab.
if !Settings.fromDefaults().isConfigured { if !Settings.fromDefaults().isConfigured {
model.selectedTab = .settings model.wrappedValue.selectedTab = .settings
} }
} }
var body: some View { var body: some View {
let showConfigurationDialog = model.selectedServer == nil
TabView(selection: $model.selectedTab) { TabView(selection: $model.selectedTab) {
Tab(.playlist, systemImage: "list.bullet", value: .playlist) { Tab(.playlist, systemImage: "list.bullet", value: .playlist) {
NavigationStack { NavigationStack {
MediaListView(model: $model.playlistModel) MediaListView(model: $model.playlistModel)
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel)
.displayingError(model.connectionError)
.withAddButton { model.onAddButtonTapped() } .withAddButton { model.onAddButtonTapped() }
.navigationTitle(.playlist) .navigationTitle(.playlist)
} }
@@ -142,6 +200,7 @@ struct MainView: View
MediaListView(model: $model.favoritesModel) MediaListView(model: $model.favoritesModel)
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel)
.displayingError(model.connectionError)
.withAddButton { model.onAddButtonTapped() } .withAddButton { model.onAddButtonTapped() }
.navigationTitle(.favorites) .navigationTitle(.favorites)
} }
@@ -151,43 +210,6 @@ struct MainView: View
SettingsView(onDone: {}) SettingsView(onDone: {})
} }
} }
#if false
VStack {
if showConfigurationDialog {
ContentPlaceholderView {
Image(systemName: "server.rack")
Text(.notConfigured)
} actions: {
Button {
isSettingsVisible = true
} label: {
Text(.settings)
}
}
} else if model.connectionError != nil {
ContentPlaceholderView {
Image(systemName: "exclamationmark.triangle.fill")
Text(.connectionError)
}
} else {
TabView(selection: $model.selectedTab) {
}
.frame(maxWidth: 640.0)
}
AddMediaBarView(model: model.addMediaViewModel)
.layoutPriority(2.0)
.disabled(showConfigurationDialog)
}
.sheet(isPresented: $isSettingsVisible) {
SettingsView(onDone: { isSettingsVisible = false })
}
#endif
} }
} }
@@ -290,6 +312,26 @@ struct AddButtonToolbarModifier: ViewModifier
} }
} }
struct ErrorDisplayModifier: ViewModifier
{
let error: Error?
func body(content: Content) -> some View {
content
.overlay {
if error != nil {
ZStack {
Rectangle()
.fill(.background)
contentPlaceholderView(title: .connectionError, systemImage: "exclamationmark.triangle.fill")
.tint(Color(uiColor: .label))
}
}
}
}
}
extension View { extension View {
func displayingServerSelectionToolbar(model: Binding<ServerSelectionToolbarModifier.ViewModel>) -> some View { func displayingServerSelectionToolbar(model: Binding<ServerSelectionToolbarModifier.ViewModel>) -> some View {
modifier(ServerSelectionToolbarModifier(model: model)) modifier(ServerSelectionToolbarModifier(model: model))
@@ -302,5 +344,9 @@ extension View {
func withAddButton(onAdd: @escaping () -> Void) -> some View { func withAddButton(onAdd: @escaping () -> Void) -> some View {
modifier(AddButtonToolbarModifier(onAdd: onAdd)) modifier(AddButtonToolbarModifier(onAdd: onAdd))
} }
func displayingError(_ error: Error?) -> some View {
modifier(ErrorDisplayModifier(error: error))
}
} }

View File

@@ -21,6 +21,7 @@ struct NowPlayingMiniView: View {
.bold() .bold()
Text(model.subtitle) Text(model.subtitle)
.lineLimit(1)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }

View File

@@ -42,7 +42,6 @@ class MediaListViewModel
var onSeek: (MediaListItem) -> Void = { _ in } var onSeek: (MediaListItem) -> Void = { _ in }
var onPlay: (MediaListItem) -> Void = { _ in } var onPlay: (MediaListItem) -> Void = { _ in }
var onDelete: (MediaListItem) -> Void = { _ in }
init(mode: MediaListMode) { init(mode: MediaListMode) {
self.mode = mode self.mode = mode
@@ -54,6 +53,15 @@ struct MediaListView: View
@Binding var model: MediaListViewModel @Binding var model: MediaListViewModel
var body: some View { var body: some View {
VStack {
if model.items.isEmpty {
let title: LocalizedStringKey = switch model.mode {
case .playlist: .playlistEmpty
case .favorites: .favoritesEmpty
}
contentPlaceholderView(title: title, systemImage: "list.bullet")
} else {
List($model.items, editActions: .delete) { item in List($model.items, editActions: .delete) { item in
let item = item.wrappedValue let item = item.wrappedValue
@@ -74,6 +82,8 @@ struct MediaListView: View
} }
} }
} }
}
}
} }

View File

@@ -100,6 +100,8 @@ struct AddServerView: View
let server = try await endpoint.resolve() let server = try await endpoint.resolve()
onAddServer(server) onAddServer(server)
model.resolvingServers.remove(endpoint)
} }
} }