diff --git a/QueueCube/Backend/API.swift b/QueueCube/Backend/API.swift index fc483e1..cd51cd1 100644 --- a/QueueCube/Backend/API.swift +++ b/QueueCube/Backend/API.swift @@ -103,6 +103,13 @@ struct API .post() } + public func deleteFavorite(mediaURL: String) async throws { + try await request() + .pathString("/favorites/\(mediaURL.uriEncoded())") + .method(.delete) + .execute() + } + public func delete(index: Int) async throws { try await request() .path("/playlist/\(index)") @@ -214,3 +221,10 @@ struct API } } } + +extension String +{ + func uriEncoded() -> Self { + return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)! + } +} diff --git a/QueueCube/Backend/Utilities.swift b/QueueCube/Backend/Utilities.swift index d7a7ca2..ae1ef10 100644 --- a/QueueCube/Backend/Utilities.swift +++ b/QueueCube/Backend/Utilities.swift @@ -37,6 +37,11 @@ struct RequestBuilder return RequestBuilder(url: self.url.appending(path: path)) } + public func pathString(_ pathString: any StringProtocol) -> Self { + // xxx: should just fix DELETE /favorites/:filename: instead. + return RequestBuilder(url: URL(string: self.url.absoluteString + pathString)!) + } + public func body(_ data: Codable) -> Self { var copy = self copy.body = try! JSONEncoder().encode(data) diff --git a/QueueCube/Views/ContentView.swift b/QueueCube/Views/ContentView.swift index 9d1e02b..83fb6f7 100644 --- a/QueueCube/Views/ContentView.swift +++ b/QueueCube/Views/ContentView.swift @@ -96,9 +96,7 @@ struct ContentView: View extension ContentView { private func refresh(_ what: RefreshType) async { - guard let api = model.selectedServer?.api else { return } - - do { + await model.withModificationsViaAPI { api in if what.contains(.nowPlaying) { let nowPlaying = try await api.fetchNowPlayingInfo() if let nowPlayingItem = nowPlaying.playingItem, let title = nowPlayingItem.title { @@ -112,6 +110,7 @@ extension ContentView model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0 model.playlistModel.isPlaying = !nowPlaying.isPaused + model.favoritesModel.isPlaying = !nowPlaying.isPaused } if what.contains(.playlist) { @@ -129,19 +128,18 @@ extension ContentView if what.contains(.favorites) { let favorites = try await api.fetchFavorites() + let nowPlaying = try await api.fetchNowPlayingInfo() model.favoritesModel.items = favorites.map { mediaItem in MediaListItem( id: String(mediaItem.id), title: mediaItem.displayTitle, - filename: mediaItem.filename ?? "" + filename: mediaItem.filename ?? "", + isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename ) } } model.connectionError = nil - } catch { - print("Error refreshing content: \(error)") - model.connectionError = error } } @@ -198,14 +196,12 @@ extension ContentView model.selectedServer = newSelectedServer // Reset view model to defaults - model.playlistModel = MediaListViewModel(mode: .playlist) - model.favoritesModel = MediaListViewModel(mode: .favorites) - model.nowPlayingViewModel = NowPlayingViewModel() + model.reset() await refresh([.playlist, .nowPlaying, .favorites]) } - // Always reset this + // Always reset this model.serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel() } } diff --git a/QueueCube/Views/MainView.swift b/QueueCube/Views/MainView.swift index fd2578d..dde10c8 100644 --- a/QueueCube/Views/MainView.swift +++ b/QueueCube/Views/MainView.swift @@ -21,6 +21,9 @@ class MainViewModel var addMediaViewModel = AddMediaBarViewModel() var serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel() + private var refreshingFromAPIDepth: UInt8 = 0 + private var isRefreshingFromAPI: Bool { refreshingFromAPIDepth > 0 } + enum Tab: String, CaseIterable { case playlist @@ -28,9 +31,82 @@ class MainViewModel case settings } + init() { + observePlaylistChanges() + } + func onAddButtonTapped() { } + + func reset() { + refreshingFromAPIDepth = 1 + + playlistModel = MediaListViewModel(mode: .playlist) + favoritesModel = MediaListViewModel(mode: .favorites) + nowPlayingViewModel = NowPlayingViewModel() + + refreshingFromAPIDepth = 0 + } + + func withModificationsViaAPI(_ modificationBlock: (API) async throws -> Void) async { + guard let api = selectedServer?.api else { return } + + refreshingFromAPIDepth += 1 + + do { + try await modificationBlock(api) + connectionError = nil + } catch { + print("Error refreshing content: \(error)") + connectionError = error + } + + refreshingFromAPIDepth -= 1 + } + + func observePlaylistChanges() { + withObservationTracking { + _ = playlistModel.items + _ = favoritesModel.items + } onChange: { [weak self] in + guard let self else { return } + + let isRefreshing = isRefreshingFromAPI + let oldPlaylist = playlistModel.items + let oldFavorites = favoritesModel.items + Task { @MainActor [weak self] in + guard let self else { return } + + if !isRefreshing { + // Notify server of removals + let playlistDiff = playlistModel.items.difference(from: oldPlaylist) { $0.id == $1.id } + await withModificationsViaAPI { api in + for removal in playlistDiff.removals { + switch removal { + case .remove(let offset, _, _): + try await api.delete(index: offset) + default: break + } + } + } + + let favoritesDiff = favoritesModel.items.difference(from: oldFavorites) { $0.id == $1.id } + await withModificationsViaAPI { api in + for removal in favoritesDiff.removals { + switch removal { + case .remove(_, let favorite, _): + try await api.deleteFavorite(mediaURL: favorite.filename) + default: break + } + } + } + } + + observePlaylistChanges() + } + } + } } struct MainView: View @@ -53,7 +129,7 @@ struct MainView: View TabView(selection: $model.selectedTab) { Tab(.playlist, systemImage: "list.bullet", value: .playlist) { NavigationStack { - MediaListView(model: model.playlistModel) + MediaListView(model: $model.playlistModel) .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) .withAddButton { model.onAddButtonTapped() } @@ -63,7 +139,7 @@ struct MainView: View Tab(.favorites, systemImage: "heart.fill", value: .favorites) { NavigationStack { - MediaListView(model: model.favoritesModel) + MediaListView(model: $model.favoritesModel) .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) .withAddButton { model.onAddButtonTapped() } diff --git a/QueueCube/Views/PlaylistView.swift b/QueueCube/Views/PlaylistView.swift index 3aecc9e..2fd1d59 100644 --- a/QueueCube/Views/PlaylistView.swift +++ b/QueueCube/Views/PlaylistView.swift @@ -51,25 +51,27 @@ class MediaListViewModel struct MediaListView: View { - var model: MediaListViewModel + @Binding var model: MediaListViewModel var body: some View { - List(model.items) { item in - MediaItemCell( - title: item.title, - subtitle: item.filename, - mode: model.mode, - state: item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued, - onLeadingIconClick: { - switch model.mode { - case .playlist: - model.onSeek(item) - case .favorites: - model.onPlay(item) - } - }, - onDeleteButtonClick: { model.onDelete(item) } - ) + 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, + ) + } } } } @@ -82,26 +84,23 @@ struct MediaItemCell: View let mode: MediaListMode let state: State - let onLeadingIconClick: () -> Void - let onDeleteButtonClick: () -> Void - var body: some View { - let icon: String = switch (mode, state) { - case (.playlist, .queued): "play.fill" - case (.playlist, .playing): "speaker.wave.3.fill" - case (.playlist, .paused): "speaker.fill" - case (.favorites, _): "play.fill" + let icon: String = switch state { + case .queued: "play.fill" + case .playing: "speaker.wave.3.fill" + case .paused: "speaker.fill" } HStack { - Button(action: onLeadingIconClick) { Image(systemName: icon) } - .buttonStyle(BorderlessButtonStyle()) + Image(systemName: icon) .tint(Color.primary) .frame(width: 15.0) + Spacer(minLength: 8.0) + VStack(alignment: .leading) { Text(title) - .font(.body.bold()) + .tint(.primary) .lineLimit(1) Text(subtitle) @@ -110,17 +109,9 @@ struct MediaItemCell: View } Spacer() - - // Show delete button for both playlist and favorites - HStack { - Button(action: onDeleteButtonClick) { - Image(systemName: "xmark") - .tint(.red) - } - } } - .listRowBackground((mode == .playlist && state != .queued) ? Color.white.opacity(0.15) : nil) - .padding([.top, .bottom], 8.0) + .listRowBackground((mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.15) : nil) + .padding([.top, .bottom], 4.0) } // MARK: - Types