From 601ffc4a75127c90e40463625fb52667284f4ba3 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 11 Jun 2025 19:33:20 -0700 Subject: [PATCH] Implements updated nowplaying view --- QueueCube/Backend/API.swift | 6 + QueueCube/Localizable/Localizable.xcstrings | 3 + QueueCube/Views/ContentView.swift | 5 + QueueCube/Views/MainView.swift | 26 ++++- QueueCube/Views/NowPlayingMiniView.swift | 13 ++- QueueCube/Views/NowPlayingView.swift | 108 ++++++++++++------ QueueCube/Views/PlaylistView.swift | 10 +- .../Settings View/GeneralSettingsView.swift | 2 +- 8 files changed, 126 insertions(+), 47 deletions(-) diff --git a/QueueCube/Backend/API.swift b/QueueCube/Backend/API.swift index 46ac48c..e4804f1 100644 --- a/QueueCube/Backend/API.swift +++ b/QueueCube/Backend/API.swift @@ -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() diff --git a/QueueCube/Localizable/Localizable.xcstrings b/QueueCube/Localizable/Localizable.xcstrings index 58f910e..a2c0bde 100644 --- a/QueueCube/Localizable/Localizable.xcstrings +++ b/QueueCube/Localizable/Localizable.xcstrings @@ -154,6 +154,9 @@ } } } + }, + "Nothing here yet." : { + }, "PLAYLIST" : { "localizations" : { diff --git a/QueueCube/Views/ContentView.swift b/QueueCube/Views/ContentView.swift index 28b87a0..309c6f0 100644 --- a/QueueCube/Views/ContentView.swift +++ b/QueueCube/Views/ContentView.swift @@ -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 diff --git a/QueueCube/Views/MainView.swift b/QueueCube/Views/MainView.swift index c1a0fcd..5b76ada 100644 --- a/QueueCube/Views/MainView.swift +++ b/QueueCube/Views/MainView.swift @@ -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) -> some View { - modifier(NowPlayingMiniPlayerModifier(model: model)) + func displayingNowPlayingMiniPlayer(model: Binding, onTap: @escaping () -> Void) -> some View { + modifier(NowPlayingMiniPlayerModifier(onTap: onTap, model: model)) } func withAddButton(onAdd: @escaping () -> Void) -> some View { diff --git a/QueueCube/Views/NowPlayingMiniView.swift b/QueueCube/Views/NowPlayingMiniView.swift index b245ebe..6bfed43 100644 --- a/QueueCube/Views/NowPlayingMiniView.swift +++ b/QueueCube/Views/NowPlayingMiniView.swift @@ -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) } } diff --git a/QueueCube/Views/NowPlayingView.swift b/QueueCube/Views/NowPlayingView.swift index 65dde23..764659a 100644 --- a/QueueCube/Views/NowPlayingView.swift +++ b/QueueCube/Views/NowPlayingView.swift @@ -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)) } } diff --git a/QueueCube/Views/PlaylistView.swift b/QueueCube/Views/PlaylistView.swift index 08969b4..5b9b3a4 100644 --- a/QueueCube/Views/PlaylistView.swift +++ b/QueueCube/Views/PlaylistView.swift @@ -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) } diff --git a/QueueCube/Views/Settings View/GeneralSettingsView.swift b/QueueCube/Views/Settings View/GeneralSettingsView.swift index 9851f44..7f37230 100644 --- a/QueueCube/Views/Settings View/GeneralSettingsView.swift +++ b/QueueCube/Views/Settings View/GeneralSettingsView.swift @@ -10,7 +10,7 @@ import SwiftUI struct GeneralSettingsView: View { var body: some View { - EmptyView() + Text("Nothing here yet.") } }