diff --git a/QueueCube/Backend/API.swift b/QueueCube/Backend/API.swift index cd51cd1..46ac48c 100644 --- a/QueueCube/Backend/API.swift +++ b/QueueCube/Backend/API.swift @@ -177,6 +177,7 @@ struct API } } catch { print("Websocket Error: \(error)") + continuation.yield(.error(API.Error.websocketError(error))) } } diff --git a/QueueCube/Localizable/Localizable.xcstrings b/QueueCube/Localizable/Localizable.xcstrings index 5a26ac5..58f910e 100644 --- a/QueueCube/Localizable/Localizable.xcstrings +++ b/QueueCube/Localizable/Localizable.xcstrings @@ -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" : { diff --git a/QueueCube/Localizable/Strings.swift b/QueueCube/Localizable/Strings.swift index 6bdb9a5..bb9a6b5 100644 --- a/QueueCube/Localizable/Strings.swift +++ b/QueueCube/Localizable/Strings.swift @@ -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") } diff --git a/QueueCube/Views/ContentPlaceholderView.swift b/QueueCube/Views/ContentPlaceholderView.swift index de3da0c..4bc548b 100644 --- a/QueueCube/Views/ContentPlaceholderView.swift +++ b/QueueCube/Views/ContentPlaceholderView.swift @@ -48,6 +48,7 @@ func contentPlaceholderView( Text(title) + .foregroundStyle(.tint) .bold() Spacer() diff --git a/QueueCube/Views/ContentView.swift b/QueueCube/Views/ContentView.swift index 83fb6f7..28b87a0 100644 --- a/QueueCube/Views/ContentView.swift +++ b/QueueCube/Views/ContentView.swift @@ -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]) } diff --git a/QueueCube/Views/MainView.swift b/QueueCube/Views/MainView.swift index dde10c8..c1a0fcd 100644 --- a/QueueCube/Views/MainView.swift +++ b/QueueCube/Views/MainView.swift @@ -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() + } - playlistModel = MediaListViewModel(mode: .playlist) - favoritesModel = MediaListViewModel(mode: .favorites) - nowPlayingViewModel = NowPlayingViewModel() + 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) + } - refreshingFromAPIDepth = 0 + // 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(_ 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) { + 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) -> 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)) + } } diff --git a/QueueCube/Views/NowPlayingMiniView.swift b/QueueCube/Views/NowPlayingMiniView.swift index a9e68a6..b245ebe 100644 --- a/QueueCube/Views/NowPlayingMiniView.swift +++ b/QueueCube/Views/NowPlayingMiniView.swift @@ -21,6 +21,7 @@ struct NowPlayingMiniView: View { .bold() Text(model.subtitle) + .lineLimit(1) .font(.caption) .foregroundStyle(.secondary) } diff --git a/QueueCube/Views/PlaylistView.swift b/QueueCube/Views/PlaylistView.swift index 2fd1d59..08969b4 100644 --- a/QueueCube/Views/PlaylistView.swift +++ b/QueueCube/Views/PlaylistView.swift @@ -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,23 +53,34 @@ struct MediaListView: View @Binding var model: MediaListViewModel var body: some View { - List($model.items, editActions: .delete) { item in - let item = item.wrappedValue - - Button { - switch model.mode { - case .playlist: - model.onSeek(item) - case .favorites: - model.onPlay(item) + 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 + + Button { + switch model.mode { + case .playlist: + model.onSeek(item) + case .favorites: + model.onPlay(item) + } + } label: { + MediaItemCell( + title: item.title, + subtitle: item.filename, + mode: model.mode, + state: item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued, + ) + } } - } label: { - MediaItemCell( - title: item.title, - subtitle: item.filename, - mode: model.mode, - state: item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued, - ) } } } diff --git a/QueueCube/Views/Settings View/AddServerView.swift b/QueueCube/Views/Settings View/AddServerView.swift index f788238..6eff779 100644 --- a/QueueCube/Views/Settings View/AddServerView.swift +++ b/QueueCube/Views/Settings View/AddServerView.swift @@ -100,6 +100,8 @@ struct AddServerView: View let server = try await endpoint.resolve() onAddServer(server) + + model.resolvingServers.remove(endpoint) } }