implements favorites/playlist deletion
This commit is contained in:
@@ -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)!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user