implements favorites/playlist deletion
This commit is contained in:
@@ -103,6 +103,13 @@ struct API
|
|||||||
.post()
|
.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 {
|
public func delete(index: Int) async throws {
|
||||||
try await request()
|
try await request()
|
||||||
.path("/playlist/\(index)")
|
.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))
|
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 {
|
public func body(_ data: Codable) -> Self {
|
||||||
var copy = self
|
var copy = self
|
||||||
copy.body = try! JSONEncoder().encode(data)
|
copy.body = try! JSONEncoder().encode(data)
|
||||||
|
|||||||
@@ -96,9 +96,7 @@ struct ContentView: View
|
|||||||
extension ContentView
|
extension ContentView
|
||||||
{
|
{
|
||||||
private func refresh(_ what: RefreshType) async {
|
private func refresh(_ what: RefreshType) async {
|
||||||
guard let api = model.selectedServer?.api else { return }
|
await model.withModificationsViaAPI { api in
|
||||||
|
|
||||||
do {
|
|
||||||
if what.contains(.nowPlaying) {
|
if what.contains(.nowPlaying) {
|
||||||
let nowPlaying = try await api.fetchNowPlayingInfo()
|
let nowPlaying = try await api.fetchNowPlayingInfo()
|
||||||
if let nowPlayingItem = nowPlaying.playingItem, let title = nowPlayingItem.title {
|
if let nowPlayingItem = nowPlaying.playingItem, let title = nowPlayingItem.title {
|
||||||
@@ -112,6 +110,7 @@ extension ContentView
|
|||||||
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
||||||
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
|
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
|
||||||
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
||||||
|
model.favoritesModel.isPlaying = !nowPlaying.isPaused
|
||||||
}
|
}
|
||||||
|
|
||||||
if what.contains(.playlist) {
|
if what.contains(.playlist) {
|
||||||
@@ -129,19 +128,18 @@ extension ContentView
|
|||||||
|
|
||||||
if what.contains(.favorites) {
|
if what.contains(.favorites) {
|
||||||
let favorites = try await api.fetchFavorites()
|
let favorites = try await api.fetchFavorites()
|
||||||
|
let nowPlaying = try await api.fetchNowPlayingInfo()
|
||||||
model.favoritesModel.items = favorites.map { mediaItem in
|
model.favoritesModel.items = favorites.map { mediaItem in
|
||||||
MediaListItem(
|
MediaListItem(
|
||||||
id: String(mediaItem.id),
|
id: String(mediaItem.id),
|
||||||
title: mediaItem.displayTitle,
|
title: mediaItem.displayTitle,
|
||||||
filename: mediaItem.filename ?? "<null>"
|
filename: mediaItem.filename ?? "<null>",
|
||||||
|
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
model.connectionError = nil
|
model.connectionError = nil
|
||||||
} catch {
|
|
||||||
print("Error refreshing content: \(error)")
|
|
||||||
model.connectionError = error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,14 +196,12 @@ extension ContentView
|
|||||||
model.selectedServer = newSelectedServer
|
model.selectedServer = newSelectedServer
|
||||||
|
|
||||||
// Reset view model to defaults
|
// Reset view model to defaults
|
||||||
model.playlistModel = MediaListViewModel(mode: .playlist)
|
model.reset()
|
||||||
model.favoritesModel = MediaListViewModel(mode: .favorites)
|
|
||||||
model.nowPlayingViewModel = NowPlayingViewModel()
|
|
||||||
|
|
||||||
await refresh([.playlist, .nowPlaying, .favorites])
|
await refresh([.playlist, .nowPlaying, .favorites])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always reset this
|
// Always reset this
|
||||||
model.serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
|
model.serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ class MainViewModel
|
|||||||
var addMediaViewModel = AddMediaBarViewModel()
|
var addMediaViewModel = AddMediaBarViewModel()
|
||||||
var serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
|
var serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
|
||||||
|
|
||||||
|
private var refreshingFromAPIDepth: UInt8 = 0
|
||||||
|
private var isRefreshingFromAPI: Bool { refreshingFromAPIDepth > 0 }
|
||||||
|
|
||||||
enum Tab: String, CaseIterable
|
enum Tab: String, CaseIterable
|
||||||
{
|
{
|
||||||
case playlist
|
case playlist
|
||||||
@@ -28,9 +31,82 @@ class MainViewModel
|
|||||||
case settings
|
case settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
observePlaylistChanges()
|
||||||
|
}
|
||||||
|
|
||||||
func onAddButtonTapped() {
|
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
|
struct MainView: View
|
||||||
@@ -53,7 +129,7 @@ struct MainView: View
|
|||||||
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)
|
||||||
.withAddButton { model.onAddButtonTapped() }
|
.withAddButton { model.onAddButtonTapped() }
|
||||||
@@ -63,7 +139,7 @@ struct MainView: View
|
|||||||
|
|
||||||
Tab(.favorites, systemImage: "heart.fill", value: .favorites) {
|
Tab(.favorites, systemImage: "heart.fill", value: .favorites) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
MediaListView(model: model.favoritesModel)
|
MediaListView(model: $model.favoritesModel)
|
||||||
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
||||||
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel)
|
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel)
|
||||||
.withAddButton { model.onAddButtonTapped() }
|
.withAddButton { model.onAddButtonTapped() }
|
||||||
|
|||||||
@@ -51,25 +51,27 @@ class MediaListViewModel
|
|||||||
|
|
||||||
struct MediaListView: View
|
struct MediaListView: View
|
||||||
{
|
{
|
||||||
var model: MediaListViewModel
|
@Binding var model: MediaListViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List(model.items) { item in
|
List($model.items, editActions: .delete) { item in
|
||||||
MediaItemCell(
|
let item = item.wrappedValue
|
||||||
title: item.title,
|
|
||||||
subtitle: item.filename,
|
Button {
|
||||||
mode: model.mode,
|
switch model.mode {
|
||||||
state: item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued,
|
case .playlist:
|
||||||
onLeadingIconClick: {
|
model.onSeek(item)
|
||||||
switch model.mode {
|
case .favorites:
|
||||||
case .playlist:
|
model.onPlay(item)
|
||||||
model.onSeek(item)
|
}
|
||||||
case .favorites:
|
} label: {
|
||||||
model.onPlay(item)
|
MediaItemCell(
|
||||||
}
|
title: item.title,
|
||||||
},
|
subtitle: item.filename,
|
||||||
onDeleteButtonClick: { model.onDelete(item) }
|
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 mode: MediaListMode
|
||||||
let state: State
|
let state: State
|
||||||
|
|
||||||
let onLeadingIconClick: () -> Void
|
|
||||||
let onDeleteButtonClick: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let icon: String = switch (mode, state) {
|
let icon: String = switch state {
|
||||||
case (.playlist, .queued): "play.fill"
|
case .queued: "play.fill"
|
||||||
case (.playlist, .playing): "speaker.wave.3.fill"
|
case .playing: "speaker.wave.3.fill"
|
||||||
case (.playlist, .paused): "speaker.fill"
|
case .paused: "speaker.fill"
|
||||||
case (.favorites, _): "play.fill"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: onLeadingIconClick) { Image(systemName: icon) }
|
Image(systemName: icon)
|
||||||
.buttonStyle(BorderlessButtonStyle())
|
|
||||||
.tint(Color.primary)
|
.tint(Color.primary)
|
||||||
.frame(width: 15.0)
|
.frame(width: 15.0)
|
||||||
|
|
||||||
|
Spacer(minLength: 8.0)
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.body.bold())
|
.tint(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
@@ -110,17 +109,9 @@ struct MediaItemCell: View
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
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)
|
.listRowBackground((mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.15) : nil)
|
||||||
.padding([.top, .bottom], 8.0)
|
.padding([.top, .bottom], 4.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Types
|
// MARK: - Types
|
||||||
|
|||||||
Reference in New Issue
Block a user