implements favorites/playlist deletion

This commit is contained in:
2025-06-11 15:08:17 -07:00
parent 9aa55864f8
commit ce8ece23a5
5 changed files with 133 additions and 51 deletions

View File

@@ -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)!
}
}

View File

@@ -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)

View File

@@ -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 ?? "<null>"
filename: mediaItem.filename ?? "<null>",
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()
}
}

View File

@@ -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() }

View File

@@ -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