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() .post()
} }
public func stop() async throws {
try await request()
.path("/stop")
.post()
}
public func skip(_ to: Int? = nil) async throws { public func skip(_ to: Int? = nil) async throws {
let path = if let to { "/skip/\(to)" } else { "/skip" } let path = if let to { "/skip/\(to)" } else { "/skip" }
try await request() try await request()

View File

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

View File

@@ -17,6 +17,11 @@ struct ContentView: View
.task(id: websocketRestartTrigger) { await watchWebsocket() } .task(id: websocketRestartTrigger) { await watchWebsocket() }
.task { await refresh([.nowPlaying, .playlist, .favorites]) } .task { await refresh([.nowPlaying, .playlist, .favorites]) }
.task { await watchForSettingsChanges() } .task { await watchForSettingsChanges() }
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
NowPlayingView(model: model.nowPlayingViewModel)
.presentationBackground(.regularMaterial)
.presentationDetents([ .height(320.0) ])
}
} }
// MARK: - Types // MARK: - Types

View File

@@ -15,6 +15,8 @@ class MainViewModel
var connectionError: Error? = nil var connectionError: Error? = nil
var selectedTab: Tab = .playlist var selectedTab: Tab = .playlist
var isNowPlayingSheetPresented: Bool = false
var playlistModel = MediaListViewModel(mode: .playlist) var playlistModel = MediaListViewModel(mode: .playlist)
var favoritesModel = MediaListViewModel(mode: .favorites) var favoritesModel = MediaListViewModel(mode: .favorites)
var nowPlayingViewModel = NowPlayingViewModel() var nowPlayingViewModel = NowPlayingViewModel()
@@ -40,6 +42,10 @@ class MainViewModel
} }
func onNowPlayingMiniTapped() {
isNowPlayingSheetPresented = true
}
func reset() async { func reset() async {
await withModificationsViaAPI { _ in await withModificationsViaAPI { _ in
playlistModel = MediaListViewModel(mode: .playlist) playlistModel = MediaListViewModel(mode: .playlist)
@@ -56,6 +62,10 @@ class MainViewModel
model.isPlaying ? try await api.pause() : try await api.play() 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 nowPlayingViewModel.onNext = apiCallback { _, api in
try await api.skip() try await api.skip()
} }
@@ -68,6 +78,10 @@ class MainViewModel
try await api.setVolume(model.volume) try await api.setVolume(model.volume)
} }
nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
self?.isNowPlayingSheetPresented = false
}
// Playlist // Playlist
playlistModel.onSeek = apiCallback { item, api in playlistModel.onSeek = apiCallback { item, api in
if let index = item.index { if let index = item.index {
@@ -188,7 +202,7 @@ struct MainView: View
NavigationStack { NavigationStack {
MediaListView(model: $model.playlistModel) MediaListView(model: $model.playlistModel)
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
.displayingError(model.connectionError) .displayingError(model.connectionError)
.withAddButton { model.onAddButtonTapped() } .withAddButton { model.onAddButtonTapped() }
.navigationTitle(.playlist) .navigationTitle(.playlist)
@@ -199,7 +213,7 @@ struct MainView: View
NavigationStack { NavigationStack {
MediaListView(model: $model.favoritesModel) MediaListView(model: $model.favoritesModel)
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
.displayingError(model.connectionError) .displayingError(model.connectionError)
.withAddButton { model.onAddButtonTapped() } .withAddButton { model.onAddButtonTapped() }
.navigationTitle(.favorites) .navigationTitle(.favorites)
@@ -215,6 +229,8 @@ struct MainView: View
struct NowPlayingMiniPlayerModifier: ViewModifier struct NowPlayingMiniPlayerModifier: ViewModifier
{ {
let onTap: () -> Void
@Binding var model: NowPlayingViewModel @Binding var model: NowPlayingViewModel
@State var nowPlayingHeight: CGFloat = 0.0 @State var nowPlayingHeight: CGFloat = 0.0
@@ -226,7 +242,7 @@ struct NowPlayingMiniPlayerModifier: ViewModifier
VStack { VStack {
Spacer() Spacer()
NowPlayingMiniView(model: $model) NowPlayingMiniView(model: $model, onTap: onTap)
.padding() .padding()
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGSize.self) { $0.size } .onGeometryChange(for: CGSize.self) { $0.size }
@@ -337,8 +353,8 @@ extension View {
modifier(ServerSelectionToolbarModifier(model: model)) modifier(ServerSelectionToolbarModifier(model: model))
} }
func displayingNowPlayingMiniPlayer(model: Binding<NowPlayingViewModel>) -> some View { func displayingNowPlayingMiniPlayer(model: Binding<NowPlayingViewModel>, onTap: @escaping () -> Void) -> some View {
modifier(NowPlayingMiniPlayerModifier(model: model)) modifier(NowPlayingMiniPlayerModifier(onTap: onTap, model: model))
} }
func withAddButton(onAdd: @escaping () -> Void) -> some View { func withAddButton(onAdd: @escaping () -> Void) -> some View {

View File

@@ -9,9 +9,19 @@ import SwiftUI
struct NowPlayingMiniView: View { struct NowPlayingMiniView: View {
@Binding var model: NowPlayingViewModel @Binding var model: NowPlayingViewModel
let onTap: () -> Void
@GestureState private var tapGestureState = false
var body: some View { var body: some View {
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill" let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
let tapGesture = DragGesture(minimumDistance: 0)
.updating($tapGestureState) { _, state, _ in
state = true
}
.onEnded { _ in
onTap()
}
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -35,9 +45,10 @@ struct NowPlayingMiniView: View {
.padding(EdgeInsets(top: 4.0, leading: 10.0, bottom: 4.0, trailing: 10.0)) .padding(EdgeInsets(top: 4.0, leading: 10.0, bottom: 4.0, trailing: 10.0))
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(.regularMaterial) .fill(tapGestureState ? .ultraThinMaterial : .bar)
.stroke(.ultraThinMaterial, lineWidth: 1.0) .stroke(.ultraThinMaterial, lineWidth: 1.0)
) )
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.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 class NowPlayingViewModel
{ {
var onPlayPause: (NowPlayingViewModel) -> Void = { _ in } var onPlayPause: (NowPlayingViewModel) -> Void = { _ in }
var onStop: (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 onVolumeChange: (NowPlayingViewModel) -> Void = { _ in }
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
var isPlaying: Bool = false var isPlaying: Bool = false
var title: String = "" var title: String = ""
@@ -26,52 +28,90 @@ struct NowPlayingView: View
@State var model: NowPlayingViewModel @State var model: NowPlayingViewModel
var body: some View { var body: some View {
content() NavigationStack {
.background(background()) VStack {
}
@ViewBuilder
private func content() -> some View {
HStack {
VStack(alignment: .leading) {
Text(model.title) Text(model.title)
.font(.title3) .font(.title2)
.lineLimit(1) .lineLimit(1)
.bold()
Text(model.subtitle) Text(model.subtitle)
.foregroundColor(.secondary) .font(.title3)
.font(.subheadline) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
}
.padding()
Spacer() Spacer()
controls()
.padding()
}
}
@ViewBuilder
private func controls() -> some View {
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
HStack { 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( Slider(
value: $model.volume, value: $model.volume,
in: 0.0...1.0, in: 0.0...1.0,
onEditingChanged: { _ in model.onVolumeChange(model) } onEditingChanged: { _ in model.onVolumeChange(model) }
).frame(maxWidth: 100.0) )
.padding(.horizontal, 18.0)
Button(action: { model.onPrev(model) } ) { Image(systemName: "arrow.left.to.line.compact") } Spacer()
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) } }
Button(action: { model.onNext(model) }) { Image(systemName: "arrow.right.to.line.compact") }
.padding(24.0)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
model.onSheetDismiss(model)
} label: {
Image(systemName: "xmark.circle.fill")
.tint(.secondary)
}
}
}
} }
} }
@ViewBuilder // MARK: - Types
private func background() -> some View {
RoundedRectangle(cornerRadius: 8.0) private enum Buttons: Int, CaseIterable, Identifiable {
.fill(Color(white: 0.0, opacity: 0.4)) case backward
case stop
case playPause
case forward
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) }
}
}
} }
} }

View File

@@ -64,6 +64,7 @@ struct MediaListView: View
} else { } else {
List($model.items, editActions: .delete) { item in List($model.items, editActions: .delete) { item in
let item = item.wrappedValue let item = item.wrappedValue
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
Button { Button {
switch model.mode { switch model.mode {
@@ -76,10 +77,10 @@ struct MediaListView: View
MediaItemCell( MediaItemCell(
title: item.title, title: item.title,
subtitle: item.filename, subtitle: item.filename,
mode: model.mode, state: state
state: item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued,
) )
} }
.listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil)
} }
} }
} }
@@ -91,7 +92,6 @@ struct MediaItemCell: View
{ {
let title: String let title: String
let subtitle: String let subtitle: String
let mode: MediaListMode
let state: State let state: State
var body: some View { var body: some View {
@@ -105,8 +105,7 @@ struct MediaItemCell: View
Image(systemName: icon) Image(systemName: icon)
.tint(Color.primary) .tint(Color.primary)
.frame(width: 15.0) .frame(width: 15.0)
.padding(.trailing, 10.0)
Spacer(minLength: 8.0)
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(title) Text(title)
@@ -120,7 +119,6 @@ struct MediaItemCell: View
Spacer() Spacer()
} }
.listRowBackground((mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.15) : nil)
.padding([.top, .bottom], 4.0) .padding([.top, .bottom], 4.0)
} }

View File

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