Implements updated nowplaying view
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -154,6 +154,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Nothing here yet." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"PLAYLIST" : {
|
"PLAYLIST" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -67,6 +77,10 @@ class MainViewModel
|
|||||||
nowPlayingViewModel.onVolumeChange = apiCallback { model, api in
|
nowPlayingViewModel.onVolumeChange = apiCallback { model, api in
|
||||||
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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
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()
|
.toolbar {
|
||||||
.padding()
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
model.onSheetDismiss(model)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.tint(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Types
|
||||||
private func controls() -> some View {
|
|
||||||
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
private enum Buttons: Int, CaseIterable, Identifiable {
|
||||||
|
case backward
|
||||||
|
case stop
|
||||||
|
case playPause
|
||||||
|
case forward
|
||||||
|
|
||||||
HStack {
|
var id: Int { rawValue }
|
||||||
Slider(
|
|
||||||
value: $model.volume,
|
func imageName(isPlaying: Bool) -> String {
|
||||||
in: 0.0...1.0,
|
switch self {
|
||||||
onEditingChanged: { _ in model.onVolumeChange(model) }
|
case .backward: "backward.fill"
|
||||||
).frame(maxWidth: 100.0)
|
case .stop: "stop.fill"
|
||||||
|
case .playPause: isPlaying ? "pause.fill" : "play.fill"
|
||||||
Button(action: { model.onPrev(model) } ) { Image(systemName: "arrow.left.to.line.compact") }
|
case .forward: "forward.fill"
|
||||||
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
|
}
|
||||||
Button(action: { model.onNext(model) }) { Image(systemName: "arrow.right.to.line.compact") }
|
}
|
||||||
|
|
||||||
|
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 {
|
} 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user