Implements a few more api endpoints

This commit is contained in:
2025-03-03 21:08:47 -08:00
parent fb9a6fcb9b
commit 74c0227ec7
6 changed files with 121 additions and 16 deletions

View File

@@ -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<Event> {
return AsyncStream { continuation in
let url = request()

View File

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

View File

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

View File

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

View File

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

View File

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