Implements updated nowplaying view

This commit is contained in:
2025-06-11 19:33:20 -07:00
parent bde29e7e98
commit 601ffc4a75
8 changed files with 126 additions and 47 deletions

View File

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

View File

@@ -154,6 +154,9 @@
}
}
}
},
"Nothing here yet." : {
},
"PLAYLIST" : {
"localizations" : {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct GeneralSettingsView: View
{
var body: some View {
EmptyView()
Text("Nothing here yet.")
}
}