better error handling and server switching
This commit is contained in:
@@ -177,6 +177,7 @@ struct API
|
||||
}
|
||||
} catch {
|
||||
print("Websocket Error: \(error)")
|
||||
continuation.yield(.error(API.Error.websocketError(error)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FAVORITES_IS_EMPTY" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Favorites is empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FINDING_SERVERS" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -155,6 +165,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PLAYLIST_IS_EMPTY" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Playlist is empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SERVER_IS_ONLINE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
@@ -31,4 +31,6 @@ extension LocalizedStringKey
|
||||
static let discovered = LocalizedStringKey("DISCOVERED")
|
||||
static let findingServers = LocalizedStringKey("FINDING_SERVERS")
|
||||
static let noServersConfigured = LocalizedStringKey("NO_SERVERS_CONFIGURED")
|
||||
static let playlistEmpty = LocalizedStringKey("PLAYLIST_IS_EMPTY")
|
||||
static let favoritesEmpty = LocalizedStringKey("FAVORITES_IS_EMPTY")
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ func contentPlaceholderView<Actions>(
|
||||
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.tint)
|
||||
.bold()
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -9,74 +9,12 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View
|
||||
{
|
||||
@State var model: MainViewModel
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@State var model = MainViewModel()
|
||||
@State private var websocketRestartTrigger = 0
|
||||
|
||||
var body: some View {
|
||||
MainView(model: model)
|
||||
.task { await watchWebsocket() }
|
||||
MainView(model: $model)
|
||||
.task(id: websocketRestartTrigger) { await watchWebsocket() }
|
||||
.task { await refresh([.nowPlaying, .playlist, .favorites]) }
|
||||
.task { await watchForSettingsChanges() }
|
||||
}
|
||||
@@ -138,8 +76,6 @@ extension ContentView
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
model.connectionError = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +132,10 @@ extension ContentView
|
||||
model.selectedServer = newSelectedServer
|
||||
|
||||
// Reset view model to defaults
|
||||
model.reset()
|
||||
await model.reset()
|
||||
|
||||
// Restart WebSocket connection for new server
|
||||
websocketRestartTrigger += 1
|
||||
|
||||
await refresh([.playlist, .nowPlaying, .favorites])
|
||||
}
|
||||
|
||||
@@ -33,20 +33,71 @@ class MainViewModel
|
||||
|
||||
init() {
|
||||
observePlaylistChanges()
|
||||
configureViewModelCallbacks()
|
||||
}
|
||||
|
||||
func onAddButtonTapped() {
|
||||
|
||||
}
|
||||
|
||||
func reset() {
|
||||
refreshingFromAPIDepth = 1
|
||||
|
||||
func reset() async {
|
||||
await withModificationsViaAPI { _ in
|
||||
playlistModel = MediaListViewModel(mode: .playlist)
|
||||
favoritesModel = MediaListViewModel(mode: .favorites)
|
||||
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 {
|
||||
@@ -65,7 +116,15 @@ class MainViewModel
|
||||
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 {
|
||||
_ = playlistModel.items
|
||||
_ = favoritesModel.items
|
||||
@@ -111,27 +170,26 @@ class MainViewModel
|
||||
|
||||
struct MainView: View
|
||||
{
|
||||
@State var model: MainViewModel
|
||||
@Binding var model: MainViewModel
|
||||
@State var isSettingsVisible: Bool = false
|
||||
|
||||
init(model: MainViewModel) {
|
||||
self.model = model
|
||||
init(model: Binding<MainViewModel>) {
|
||||
self._model = model
|
||||
|
||||
// If no servers are configured, make Settings the default tab.
|
||||
if !Settings.fromDefaults().isConfigured {
|
||||
model.selectedTab = .settings
|
||||
model.wrappedValue.selectedTab = .settings
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let showConfigurationDialog = model.selectedServer == nil
|
||||
|
||||
TabView(selection: $model.selectedTab) {
|
||||
Tab(.playlist, systemImage: "list.bullet", value: .playlist) {
|
||||
NavigationStack {
|
||||
MediaListView(model: $model.playlistModel)
|
||||
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
||||
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel)
|
||||
.displayingError(model.connectionError)
|
||||
.withAddButton { model.onAddButtonTapped() }
|
||||
.navigationTitle(.playlist)
|
||||
}
|
||||
@@ -142,6 +200,7 @@ struct MainView: View
|
||||
MediaListView(model: $model.favoritesModel)
|
||||
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
||||
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel)
|
||||
.displayingError(model.connectionError)
|
||||
.withAddButton { model.onAddButtonTapped() }
|
||||
.navigationTitle(.favorites)
|
||||
}
|
||||
@@ -151,43 +210,6 @@ struct MainView: View
|
||||
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 {
|
||||
func displayingServerSelectionToolbar(model: Binding<ServerSelectionToolbarModifier.ViewModel>) -> some View {
|
||||
modifier(ServerSelectionToolbarModifier(model: model))
|
||||
@@ -302,5 +344,9 @@ extension View {
|
||||
func withAddButton(onAdd: @escaping () -> Void) -> some View {
|
||||
modifier(AddButtonToolbarModifier(onAdd: onAdd))
|
||||
}
|
||||
|
||||
func displayingError(_ error: Error?) -> some View {
|
||||
modifier(ErrorDisplayModifier(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ struct NowPlayingMiniView: View {
|
||||
.bold()
|
||||
|
||||
Text(model.subtitle)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ class MediaListViewModel
|
||||
|
||||
var onSeek: (MediaListItem) -> Void = { _ in }
|
||||
var onPlay: (MediaListItem) -> Void = { _ in }
|
||||
var onDelete: (MediaListItem) -> Void = { _ in }
|
||||
|
||||
init(mode: MediaListMode) {
|
||||
self.mode = mode
|
||||
@@ -54,6 +53,15 @@ struct MediaListView: View
|
||||
@Binding var model: MediaListViewModel
|
||||
|
||||
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
|
||||
let item = item.wrappedValue
|
||||
|
||||
@@ -75,6 +83,8 @@ struct MediaListView: View
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct MediaItemCell: View
|
||||
|
||||
@@ -100,6 +100,8 @@ struct AddServerView: View
|
||||
|
||||
let server = try await endpoint.resolve()
|
||||
onAddServer(server)
|
||||
|
||||
model.resolvingServers.remove(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user