Implements updated nowplaying view
This commit is contained in:
@@ -76,6 +76,12 @@ struct API
|
||||
.post()
|
||||
}
|
||||
|
||||
public func stop() async throws {
|
||||
try await request()
|
||||
.path("/stop")
|
||||
.post()
|
||||
}
|
||||
|
||||
public func skip(_ to: Int? = nil) async throws {
|
||||
let path = if let to { "/skip/\(to)" } else { "/skip" }
|
||||
try await request()
|
||||
|
||||
@@ -154,6 +154,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Nothing here yet." : {
|
||||
|
||||
},
|
||||
"PLAYLIST" : {
|
||||
"localizations" : {
|
||||
|
||||
@@ -17,6 +17,11 @@ struct ContentView: View
|
||||
.task(id: websocketRestartTrigger) { await watchWebsocket() }
|
||||
.task { await refresh([.nowPlaying, .playlist, .favorites]) }
|
||||
.task { await watchForSettingsChanges() }
|
||||
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
|
||||
NowPlayingView(model: model.nowPlayingViewModel)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.presentationDetents([ .height(320.0) ])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
@@ -15,6 +15,8 @@ class MainViewModel
|
||||
var connectionError: Error? = nil
|
||||
var selectedTab: Tab = .playlist
|
||||
|
||||
var isNowPlayingSheetPresented: Bool = false
|
||||
|
||||
var playlistModel = MediaListViewModel(mode: .playlist)
|
||||
var favoritesModel = MediaListViewModel(mode: .favorites)
|
||||
var nowPlayingViewModel = NowPlayingViewModel()
|
||||
@@ -40,6 +42,10 @@ class MainViewModel
|
||||
|
||||
}
|
||||
|
||||
func onNowPlayingMiniTapped() {
|
||||
isNowPlayingSheetPresented = true
|
||||
}
|
||||
|
||||
func reset() async {
|
||||
await withModificationsViaAPI { _ in
|
||||
playlistModel = MediaListViewModel(mode: .playlist)
|
||||
@@ -56,6 +62,10 @@ class MainViewModel
|
||||
model.isPlaying ? try await api.pause() : try await api.play()
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onStop = apiCallback { model, api in
|
||||
try await api.stop()
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onNext = apiCallback { _, api in
|
||||
try await api.skip()
|
||||
}
|
||||
@@ -67,6 +77,10 @@ class MainViewModel
|
||||
nowPlayingViewModel.onVolumeChange = apiCallback { model, api in
|
||||
try await api.setVolume(model.volume)
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
|
||||
self?.isNowPlayingSheetPresented = false
|
||||
}
|
||||
|
||||
// Playlist
|
||||
playlistModel.onSeek = apiCallback { item, api in
|
||||
@@ -188,7 +202,7 @@ struct MainView: View
|
||||
NavigationStack {
|
||||
MediaListView(model: $model.playlistModel)
|
||||
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
||||
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel)
|
||||
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
|
||||
.displayingError(model.connectionError)
|
||||
.withAddButton { model.onAddButtonTapped() }
|
||||
.navigationTitle(.playlist)
|
||||
@@ -199,7 +213,7 @@ struct MainView: View
|
||||
NavigationStack {
|
||||
MediaListView(model: $model.favoritesModel)
|
||||
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
||||
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel)
|
||||
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
|
||||
.displayingError(model.connectionError)
|
||||
.withAddButton { model.onAddButtonTapped() }
|
||||
.navigationTitle(.favorites)
|
||||
@@ -215,6 +229,8 @@ struct MainView: View
|
||||
|
||||
struct NowPlayingMiniPlayerModifier: ViewModifier
|
||||
{
|
||||
let onTap: () -> Void
|
||||
|
||||
@Binding var model: NowPlayingViewModel
|
||||
@State var nowPlayingHeight: CGFloat = 0.0
|
||||
|
||||
@@ -226,7 +242,7 @@ struct NowPlayingMiniPlayerModifier: ViewModifier
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
NowPlayingMiniView(model: $model)
|
||||
NowPlayingMiniView(model: $model, onTap: onTap)
|
||||
.padding()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.onGeometryChange(for: CGSize.self) { $0.size }
|
||||
@@ -337,8 +353,8 @@ extension View {
|
||||
modifier(ServerSelectionToolbarModifier(model: model))
|
||||
}
|
||||
|
||||
func displayingNowPlayingMiniPlayer(model: Binding<NowPlayingViewModel>) -> some View {
|
||||
modifier(NowPlayingMiniPlayerModifier(model: model))
|
||||
func displayingNowPlayingMiniPlayer(model: Binding<NowPlayingViewModel>, onTap: @escaping () -> Void) -> some View {
|
||||
modifier(NowPlayingMiniPlayerModifier(onTap: onTap, model: model))
|
||||
}
|
||||
|
||||
func withAddButton(onAdd: @escaping () -> Void) -> some View {
|
||||
|
||||
@@ -9,9 +9,19 @@ import SwiftUI
|
||||
|
||||
struct NowPlayingMiniView: View {
|
||||
@Binding var model: NowPlayingViewModel
|
||||
let onTap: () -> Void
|
||||
|
||||
@GestureState private var tapGestureState = false
|
||||
|
||||
var body: some View {
|
||||
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
||||
let tapGesture = DragGesture(minimumDistance: 0)
|
||||
.updating($tapGestureState) { _, state, _ in
|
||||
state = true
|
||||
}
|
||||
.onEnded { _ in
|
||||
onTap()
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
@@ -35,9 +45,10 @@ struct NowPlayingMiniView: View {
|
||||
.padding(EdgeInsets(top: 4.0, leading: 10.0, bottom: 4.0, trailing: 10.0))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.regularMaterial)
|
||||
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
||||
.stroke(.ultraThinMaterial, lineWidth: 1.0)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
|
||||
.gesture(tapGesture)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import SwiftUI
|
||||
class NowPlayingViewModel
|
||||
{
|
||||
var onPlayPause: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onStop: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onVolumeChange: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
|
||||
|
||||
var isPlaying: Bool = false
|
||||
var title: String = ""
|
||||
@@ -26,52 +28,90 @@ struct NowPlayingView: View
|
||||
@State var model: NowPlayingViewModel
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.background(background())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content() -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
Text(model.title)
|
||||
.font(.title3)
|
||||
.font(.title2)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
|
||||
Text(model.subtitle)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.subheadline)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
ForEach(Buttons.allCases) { button in
|
||||
Spacer()
|
||||
|
||||
Button(action: button.action(model: model)) {
|
||||
Image(systemName: button.imageName(isPlaying: model.isPlaying))
|
||||
.resizable()
|
||||
.aspectRatio(1.0, contentMode: .fit)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.tint(Color(uiColor: .label))
|
||||
.imageScale(.large)
|
||||
.frame(height: 34.0)
|
||||
|
||||
Spacer()
|
||||
|
||||
Slider(
|
||||
value: $model.volume,
|
||||
in: 0.0...1.0,
|
||||
onEditingChanged: { _ in model.onVolumeChange(model) }
|
||||
)
|
||||
.padding(.horizontal, 18.0)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
.padding(24.0)
|
||||
|
||||
controls()
|
||||
.padding()
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
model.onSheetDismiss(model)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func controls() -> some View {
|
||||
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
||||
// MARK: - Types
|
||||
|
||||
private enum Buttons: Int, CaseIterable, Identifiable {
|
||||
case backward
|
||||
case stop
|
||||
case playPause
|
||||
case forward
|
||||
|
||||
HStack {
|
||||
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) }
|
||||
Button(action: { model.onNext(model) }) { Image(systemName: "arrow.right.to.line.compact") }
|
||||
var id: Int { rawValue }
|
||||
|
||||
func imageName(isPlaying: Bool) -> String {
|
||||
switch self {
|
||||
case .backward: "backward.fill"
|
||||
case .stop: "stop.fill"
|
||||
case .playPause: isPlaying ? "pause.fill" : "play.fill"
|
||||
case .forward: "forward.fill"
|
||||
}
|
||||
}
|
||||
|
||||
func action(model: NowPlayingViewModel) -> () -> Void {
|
||||
switch self {
|
||||
case .backward: { model.onPrev(model) }
|
||||
case .stop: { model.onStop(model) }
|
||||
case .playPause: { model.onPlayPause(model) }
|
||||
case .forward: { model.onNext(model) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func background() -> some View {
|
||||
RoundedRectangle(cornerRadius: 8.0)
|
||||
.fill(Color(white: 0.0, opacity: 0.4))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ struct MediaListView: View
|
||||
} else {
|
||||
List($model.items, editActions: .delete) { item in
|
||||
let item = item.wrappedValue
|
||||
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
|
||||
|
||||
Button {
|
||||
switch model.mode {
|
||||
@@ -76,10 +77,10 @@ struct MediaListView: View
|
||||
MediaItemCell(
|
||||
title: item.title,
|
||||
subtitle: item.filename,
|
||||
mode: model.mode,
|
||||
state: item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued,
|
||||
state: state
|
||||
)
|
||||
}
|
||||
.listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,7 +92,6 @@ struct MediaItemCell: View
|
||||
{
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let mode: MediaListMode
|
||||
let state: State
|
||||
|
||||
var body: some View {
|
||||
@@ -105,8 +105,7 @@ struct MediaItemCell: View
|
||||
Image(systemName: icon)
|
||||
.tint(Color.primary)
|
||||
.frame(width: 15.0)
|
||||
|
||||
Spacer(minLength: 8.0)
|
||||
.padding(.trailing, 10.0)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
@@ -120,7 +119,6 @@ struct MediaItemCell: View
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground((mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.15) : nil)
|
||||
.padding([.top, .bottom], 4.0)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
struct GeneralSettingsView: View
|
||||
{
|
||||
var body: some View {
|
||||
EmptyView()
|
||||
Text("Nothing here yet.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user