better error handling and server switching
This commit is contained in:
@@ -177,6 +177,7 @@ struct API
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Websocket Error: \(error)")
|
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" : {
|
"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" : {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func contentPlaceholderView<Actions>(
|
|||||||
|
|
||||||
|
|
||||||
Text(title)
|
Text(title)
|
||||||
|
.foregroundStyle(.tint)
|
||||||
.bold()
|
.bold()
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -75,6 +83,8 @@ struct MediaListView: View
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
struct MediaItemCell: View
|
struct MediaItemCell: View
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user