From 74c0227ec70992afae47ec5bf71f884eb9d5c7a2 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 3 Mar 2025 21:08:47 -0800 Subject: [PATCH] Implements a few more api endpoints --- QueueCube/API.swift | 34 ++++++++++++++++++++++++++ QueueCube/AddMediaBarView.swift | 4 ++-- QueueCube/ContentView.swift | 42 ++++++++++++++++++++++++++++++--- QueueCube/NowPlayingView.swift | 14 +++++++---- QueueCube/PlaylistView.swift | 17 +++++++++---- QueueCube/Utilities.swift | 26 ++++++++++++++++++-- 6 files changed, 121 insertions(+), 16 deletions(-) diff --git a/QueueCube/API.swift b/QueueCube/API.swift index abdd1bb..5f6819e 100644 --- a/QueueCube/API.swift +++ b/QueueCube/API.swift @@ -66,6 +66,40 @@ struct API .post() } + public func skip(_ to: Int? = nil) async throws { + let path = if let to { "/skip/\(to)" } else { "/skip" } + try await request() + .path(path) + .post() + } + + public func previous() async throws { + try await request() + .path("/previous") + .post() + } + + public func add(mediaURL: String) async throws { + try await request() + .path("/playlist") + .body([ "url" : mediaURL ]) + .post() + } + + public func delete(index: Int) async throws { + try await request() + .path("/playlist/\(index)") + .method(.delete) + .execute() + } + + public func setVolume(_ value: Double) async throws { + try await request() + .path("/volume") + .body([ "volume" : Int(value * 100) ]) + .post() + } + public func events() async throws -> AsyncStream { return AsyncStream { continuation in let url = request() diff --git a/QueueCube/AddMediaBarView.swift b/QueueCube/AddMediaBarView.swift index 9b1ddcd..5e57d1a 100644 --- a/QueueCube/AddMediaBarView.swift +++ b/QueueCube/AddMediaBarView.swift @@ -12,8 +12,8 @@ class AddMediaBarViewModel { var fieldContents: String = "" - let onAdd: (String) -> Void = { _ in } - let onSearch: () -> Void = {} + var onAdd: (String) -> Void = { _ in } + var onSearch: () -> Void = {} } struct AddMediaBarView: View diff --git a/QueueCube/ContentView.swift b/QueueCube/ContentView.swift index 1ab8b1e..c70f59b 100644 --- a/QueueCube/ContentView.swift +++ b/QueueCube/ContentView.swift @@ -15,9 +15,37 @@ struct ContentView: View self.model = MainViewModel() let api = self.model.api - self.model.nowPlayingViewModel.onPlayPause = { model in + + 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 { try await api.skip(item.index) } + } + playlistModel.onDelete = { item in + Task { try await api.delete(index: item.index) } + } + + let addMediaModel = model.addMediaViewModel + addMediaModel.onAdd = { mediaURL in + let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !strippedURL.isEmpty { + addMediaModel.fieldContents = "" + Task { try await api.add(mediaURL: strippedURL) } + } + } } var body: some View { @@ -46,13 +74,15 @@ extension ContentView model.nowPlayingViewModel.title = nowPlaying.playingItem?.title ?? "??" model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename ?? "??" model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused + model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0 model.playlistModel.isPlaying = !nowPlaying.isPaused } if what.contains(.playlist) { let playlist = try await model.api.fetchPlaylist() - model.playlistModel.items = playlist.map { mediaItem in + model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in PlaylistItem( + index: idx, id: String(mediaItem.id), title: mediaItem.title ?? mediaItem.filename, filename: mediaItem.filename, @@ -68,6 +98,7 @@ extension ContentView private func watchWebsocket() async { do { for await event in try await model.api.events() { + print("Got event: \(event.type)") await handle(event: event) } } catch { @@ -81,6 +112,9 @@ extension ContentView case .nowPlayingUpdate: await refresh(.nowPlaying) + case .playlistUpdate: + await refresh(.playlist) + case .metadataUpdate: fallthrough case .mpdUpdate: await refresh([.playlist, .nowPlaying]) @@ -109,11 +143,13 @@ struct MainView: View VStack { VStack { NowPlayingView(model: model.nowPlayingViewModel) + .padding() + PlaylistView(model: model.playlistModel) + .frame(maxWidth: 640.0) } .frame(minHeight: 0.0) .layoutPriority(1.0) - .padding() AddMediaBarView(model: model.addMediaViewModel) .layoutPriority(2.0) diff --git a/QueueCube/NowPlayingView.swift b/QueueCube/NowPlayingView.swift index fc8321f..4fa8712 100644 --- a/QueueCube/NowPlayingView.swift +++ b/QueueCube/NowPlayingView.swift @@ -10,9 +10,10 @@ import SwiftUI @Observable class NowPlayingViewModel { - var onPlayPause: (NowPlayingViewModel) -> Void = { _ in } - var onNext: (NowPlayingViewModel) -> Void = { _ in } - var onPrev: (NowPlayingViewModel) -> Void = { _ in } + var onPlayPause: (NowPlayingViewModel) -> Void = { _ in } + var onNext: (NowPlayingViewModel) -> Void = { _ in } + var onPrev: (NowPlayingViewModel) -> Void = { _ in } + var onVolumeChange: (NowPlayingViewModel) -> Void = { _ in } var isPlaying: Bool = false var title: String = "Loading…" @@ -56,8 +57,11 @@ struct NowPlayingView: View let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill" HStack { - Slider(value: $model.volume, in: 0.0...1.0) - .frame(maxWidth: 100.0) + Slider( + value: $model.volume, + in: 0.0...1.0, + onEditingChanged: { _ in model.onVolumeChange(model) } + ).frame(maxWidth: 100.0) Button(action: { model.onPrev(model) } ) { Image(systemName: "arrow.left.to.line.compact") } Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) } diff --git a/QueueCube/PlaylistView.swift b/QueueCube/PlaylistView.swift index 0d5bda1..cc5ec35 100644 --- a/QueueCube/PlaylistView.swift +++ b/QueueCube/PlaylistView.swift @@ -9,6 +9,7 @@ import SwiftUI struct PlaylistItem: Identifiable { + let index: Int let id: String let title: String let filename: String @@ -20,6 +21,9 @@ class PlaylistViewModel { var isPlaying: Bool = false var items: [PlaylistItem] = [] + + var onSeek: (PlaylistItem) -> Void = { _ in } + var onDelete: (PlaylistItem) -> Void = { _ in } } struct PlaylistView: View @@ -32,7 +36,9 @@ struct PlaylistView: View title: item.title, subtitle: item.filename, state: item.isCurrent ? (model.isPlaying ? PlaylistItemCell.State.playing : PlaylistItemCell.State.paused) - : .queued + : .queued, + onLeadingIconClick: { model.onSeek(item) }, + onDeleteButtonClick: { model.onDelete(item) }, ) } } @@ -44,6 +50,9 @@ struct PlaylistItemCell: View let subtitle: String let state: State + let onLeadingIconClick: () -> Void + let onDeleteButtonClick: () -> Void + var body: some View { let icon: String = switch state { case .queued: "play.fill" @@ -52,7 +61,7 @@ struct PlaylistItemCell: View } HStack { - Button(action: {}) { Image(systemName: icon) } + Button(action: onLeadingIconClick) { Image(systemName: icon) } .buttonStyle(BorderlessButtonStyle()) .tint(Color.primary) .frame(width: 15.0) @@ -70,14 +79,14 @@ struct PlaylistItemCell: View Spacer() HStack { - Button(action: {}) { + Button(action: onDeleteButtonClick) { Image(systemName: "xmark") .tint(.red) } } } .listRowBackground(state != .queued ? Color.white.opacity(0.15) : nil) - .padding([.top, .bottom], 8.0) + .padding([.top, .bottom], 8.0) } // MARK: - Types diff --git a/QueueCube/Utilities.swift b/QueueCube/Utilities.swift index 3d961ca..8cf95a9 100644 --- a/QueueCube/Utilities.swift +++ b/QueueCube/Utilities.swift @@ -11,6 +11,7 @@ struct RequestBuilder { let url: URL private var httpMethod: HTTPMethod = .get + private var body: Data? = nil init(url: URL) { self.url = url @@ -26,9 +27,20 @@ struct RequestBuilder return RequestBuilder(url: self.url.appending(path: path)) } + public func body(_ data: Codable) -> Self { + var copy = self + copy.body = try! JSONEncoder().encode(data) + return copy + } + public func build() -> URLRequest { var request = URLRequest(url: self.url) request.httpMethod = self.httpMethod.rawValue + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = body + } + return request } @@ -39,8 +51,17 @@ struct RequestBuilder } public func post() async throws { - let urlRequest = self.method(.post).build() - (_, _) = try await URLSession.shared.data(for: urlRequest) + try await self.method(.post).execute() + } + + public func execute() async throws { + let urlRequest = self.build() + let (data, response) = try await URLSession.shared.data(for: urlRequest) + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode != 200 { + print("POST error \(httpResponse.statusCode): \(String(data: data, encoding: .utf8)!)") + } + } } public func websocket() -> URL { @@ -52,5 +73,6 @@ struct RequestBuilder enum HTTPMethod: String { case get = "GET" case post = "POST" + case delete = "DELETE" } }