Implements a few more api endpoints
This commit is contained in:
@@ -66,6 +66,40 @@ struct API
|
|||||||
.post()
|
.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> {
|
public func events() async throws -> AsyncStream<Event> {
|
||||||
return AsyncStream { continuation in
|
return AsyncStream { continuation in
|
||||||
let url = request()
|
let url = request()
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ class AddMediaBarViewModel
|
|||||||
{
|
{
|
||||||
var fieldContents: String = ""
|
var fieldContents: String = ""
|
||||||
|
|
||||||
let onAdd: (String) -> Void = { _ in }
|
var onAdd: (String) -> Void = { _ in }
|
||||||
let onSearch: () -> Void = {}
|
var onSearch: () -> Void = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AddMediaBarView: View
|
struct AddMediaBarView: View
|
||||||
|
|||||||
@@ -15,9 +15,37 @@ struct ContentView: View
|
|||||||
self.model = MainViewModel()
|
self.model = MainViewModel()
|
||||||
|
|
||||||
let api = self.model.api
|
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() }
|
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 {
|
var body: some View {
|
||||||
@@ -46,13 +74,15 @@ extension ContentView
|
|||||||
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title ?? "??"
|
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title ?? "??"
|
||||||
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename ?? "??"
|
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename ?? "??"
|
||||||
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
||||||
|
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
|
||||||
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
||||||
}
|
}
|
||||||
|
|
||||||
if what.contains(.playlist) {
|
if what.contains(.playlist) {
|
||||||
let playlist = try await model.api.fetchPlaylist()
|
let playlist = try await model.api.fetchPlaylist()
|
||||||
model.playlistModel.items = playlist.map { mediaItem in
|
model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in
|
||||||
PlaylistItem(
|
PlaylistItem(
|
||||||
|
index: idx,
|
||||||
id: String(mediaItem.id),
|
id: String(mediaItem.id),
|
||||||
title: mediaItem.title ?? mediaItem.filename,
|
title: mediaItem.title ?? mediaItem.filename,
|
||||||
filename: mediaItem.filename,
|
filename: mediaItem.filename,
|
||||||
@@ -68,6 +98,7 @@ extension ContentView
|
|||||||
private func watchWebsocket() async {
|
private func watchWebsocket() async {
|
||||||
do {
|
do {
|
||||||
for await event in try await model.api.events() {
|
for await event in try await model.api.events() {
|
||||||
|
print("Got event: \(event.type)")
|
||||||
await handle(event: event)
|
await handle(event: event)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -81,6 +112,9 @@ extension ContentView
|
|||||||
case .nowPlayingUpdate:
|
case .nowPlayingUpdate:
|
||||||
await refresh(.nowPlaying)
|
await refresh(.nowPlaying)
|
||||||
|
|
||||||
|
case .playlistUpdate:
|
||||||
|
await refresh(.playlist)
|
||||||
|
|
||||||
case .metadataUpdate: fallthrough
|
case .metadataUpdate: fallthrough
|
||||||
case .mpdUpdate:
|
case .mpdUpdate:
|
||||||
await refresh([.playlist, .nowPlaying])
|
await refresh([.playlist, .nowPlaying])
|
||||||
@@ -109,11 +143,13 @@ struct MainView: View
|
|||||||
VStack {
|
VStack {
|
||||||
VStack {
|
VStack {
|
||||||
NowPlayingView(model: model.nowPlayingViewModel)
|
NowPlayingView(model: model.nowPlayingViewModel)
|
||||||
|
.padding()
|
||||||
|
|
||||||
PlaylistView(model: model.playlistModel)
|
PlaylistView(model: model.playlistModel)
|
||||||
|
.frame(maxWidth: 640.0)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 0.0)
|
.frame(minHeight: 0.0)
|
||||||
.layoutPriority(1.0)
|
.layoutPriority(1.0)
|
||||||
.padding()
|
|
||||||
|
|
||||||
AddMediaBarView(model: model.addMediaViewModel)
|
AddMediaBarView(model: model.addMediaViewModel)
|
||||||
.layoutPriority(2.0)
|
.layoutPriority(2.0)
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import SwiftUI
|
|||||||
@Observable
|
@Observable
|
||||||
class NowPlayingViewModel
|
class NowPlayingViewModel
|
||||||
{
|
{
|
||||||
var onPlayPause: (NowPlayingViewModel) -> Void = { _ in }
|
var onPlayPause: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
var onVolumeChange: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
|
||||||
var isPlaying: Bool = false
|
var isPlaying: Bool = false
|
||||||
var title: String = "Loading…"
|
var title: String = "Loading…"
|
||||||
@@ -56,8 +57,11 @@ struct NowPlayingView: View
|
|||||||
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Slider(value: $model.volume, in: 0.0...1.0)
|
Slider(
|
||||||
.frame(maxWidth: 100.0)
|
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.onPrev(model) } ) { Image(systemName: "arrow.left.to.line.compact") }
|
||||||
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
|
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct PlaylistItem: Identifiable
|
struct PlaylistItem: Identifiable
|
||||||
{
|
{
|
||||||
|
let index: Int
|
||||||
let id: String
|
let id: String
|
||||||
let title: String
|
let title: String
|
||||||
let filename: String
|
let filename: String
|
||||||
@@ -20,6 +21,9 @@ class PlaylistViewModel
|
|||||||
{
|
{
|
||||||
var isPlaying: Bool = false
|
var isPlaying: Bool = false
|
||||||
var items: [PlaylistItem] = []
|
var items: [PlaylistItem] = []
|
||||||
|
|
||||||
|
var onSeek: (PlaylistItem) -> Void = { _ in }
|
||||||
|
var onDelete: (PlaylistItem) -> Void = { _ in }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlaylistView: View
|
struct PlaylistView: View
|
||||||
@@ -32,7 +36,9 @@ struct PlaylistView: View
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.filename,
|
subtitle: item.filename,
|
||||||
state: item.isCurrent ? (model.isPlaying ? PlaylistItemCell.State.playing : PlaylistItemCell.State.paused)
|
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 subtitle: String
|
||||||
let state: State
|
let state: State
|
||||||
|
|
||||||
|
let onLeadingIconClick: () -> Void
|
||||||
|
let onDeleteButtonClick: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let icon: String = switch state {
|
let icon: String = switch state {
|
||||||
case .queued: "play.fill"
|
case .queued: "play.fill"
|
||||||
@@ -52,7 +61,7 @@ struct PlaylistItemCell: View
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {}) { Image(systemName: icon) }
|
Button(action: onLeadingIconClick) { Image(systemName: icon) }
|
||||||
.buttonStyle(BorderlessButtonStyle())
|
.buttonStyle(BorderlessButtonStyle())
|
||||||
.tint(Color.primary)
|
.tint(Color.primary)
|
||||||
.frame(width: 15.0)
|
.frame(width: 15.0)
|
||||||
@@ -70,14 +79,14 @@ struct PlaylistItemCell: View
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {}) {
|
Button(action: onDeleteButtonClick) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(state != .queued ? Color.white.opacity(0.15) : nil)
|
.listRowBackground(state != .queued ? Color.white.opacity(0.15) : nil)
|
||||||
.padding([.top, .bottom], 8.0)
|
.padding([.top, .bottom], 8.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Types
|
// MARK: - Types
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct RequestBuilder
|
|||||||
{
|
{
|
||||||
let url: URL
|
let url: URL
|
||||||
private var httpMethod: HTTPMethod = .get
|
private var httpMethod: HTTPMethod = .get
|
||||||
|
private var body: Data? = nil
|
||||||
|
|
||||||
init(url: URL) {
|
init(url: URL) {
|
||||||
self.url = url
|
self.url = url
|
||||||
@@ -26,9 +27,20 @@ struct RequestBuilder
|
|||||||
return RequestBuilder(url: self.url.appending(path: path))
|
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 {
|
public func build() -> URLRequest {
|
||||||
var request = URLRequest(url: self.url)
|
var request = URLRequest(url: self.url)
|
||||||
request.httpMethod = self.httpMethod.rawValue
|
request.httpMethod = self.httpMethod.rawValue
|
||||||
|
if let body {
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = body
|
||||||
|
}
|
||||||
|
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +51,17 @@ struct RequestBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func post() async throws {
|
public func post() async throws {
|
||||||
let urlRequest = self.method(.post).build()
|
try await self.method(.post).execute()
|
||||||
(_, _) = try await URLSession.shared.data(for: urlRequest)
|
}
|
||||||
|
|
||||||
|
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 {
|
public func websocket() -> URL {
|
||||||
@@ -52,5 +73,6 @@ struct RequestBuilder
|
|||||||
enum HTTPMethod: String {
|
enum HTTPMethod: String {
|
||||||
case get = "GET"
|
case get = "GET"
|
||||||
case post = "POST"
|
case post = "POST"
|
||||||
|
case delete = "DELETE"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user