From 4f9b5a9aa5143cdf8711e05b04e3eae5fd6612e3 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 20:54:04 -0400 Subject: [PATCH 01/10] Adds animations to the FloatingPlayerView for failing to dismiss and dismissing. --- flo/ContentView.swift | 141 ++++++++++++++++++++++++++---------------- 1 file changed, 87 insertions(+), 54 deletions(-) diff --git a/flo/ContentView.swift b/flo/ContentView.swift index ac12301..93596c5 100644 --- a/flo/ContentView.swift +++ b/flo/ContentView.swift @@ -7,6 +7,7 @@ import PulseUI import SwiftUI +import Combine struct ContentView: View { @AppStorage(UserDefaultsKeys.enableDebug) private var enableDebug = false @@ -24,8 +25,11 @@ struct ContentView: View { @State private var floatingPlayerOffsetX: CGFloat = .zero @State private var isSwipping = false - - private var swipeThreshold: CGFloat = 150.0 + @State private var keyboardHeight: CGFloat = 0 + + private var swipeThreshold: CGFloat { + UIScreen.main.bounds.width * 0.3 + } private var isPadSidebar: Bool { guard UIDevice.current.userInterfaceIdiom == .pad else { return false } @@ -114,40 +118,48 @@ struct ContentView: View { } @available(iOS 18.0, *) - private func sidebarTabContent(_ content: Content) -> some View { - content - .overlay(alignment: .bottom) { - if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { - FloatingPlayerView(viewModel: playerViewModel) - .frame(maxWidth: 720) - .opacity(playerViewModel.hasNowPlaying() ? 1 : 0) - .offset(x: floatingPlayerOffsetX) - .onTapGesture { - self.isPlayerExpanded = true - } - .gesture( - DragGesture() - .onChanged { value in - if value.translation.width < .zero { - floatingPlayerOffsetX = value.translation.width - } - - if abs(floatingPlayerOffsetX) > swipeThreshold, !isSwipping { - isSwipping = true - } - } - .onEnded { _ in - if abs(floatingPlayerOffsetX) > swipeThreshold, isSwipping { - playerViewModel.destroyPlayerAndQueue() - } - - self.floatingPlayerOffsetX = .zero - self.isSwipping = false + private func sidebarTabContent(_ content: Content) -> some View { + content + .overlay(alignment: .bottom) { + if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { + FloatingPlayerView(viewModel: playerViewModel) + .padding(.horizontal, 20) + .opacity(playerViewModel.hasNowPlaying() ? 1 : 0) + .offset(x: floatingPlayerOffsetX) + .onTapGesture { + self.isPlayerExpanded = true + } + .gesture( + DragGesture() + .onChanged { value in + if value.translation.width < .zero { + floatingPlayerOffsetX = value.translation.width + } + + if abs(floatingPlayerOffsetX) > swipeThreshold, !isSwipping { + isSwipping = true + } + } + .onEnded { _ in + if abs(floatingPlayerOffsetX) > swipeThreshold, isSwipping { + withAnimation(.easeOut(duration: 0.25)) { + self.floatingPlayerOffsetX = -UIScreen.main.bounds.width + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + playerViewModel.destroyPlayerAndQueue() + self.floatingPlayerOffsetX = .zero + } + } else { + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)){ + self.floatingPlayerOffsetX = .zero + } + } + self.isSwipping = false + } + ) } - ) - } - } - } + } + } @available(iOS 18.0, *) private var sidebarTabView: some View { @@ -327,24 +339,34 @@ struct ContentView: View { self.isPlayerExpanded = true } .gesture( - DragGesture() - .onChanged { value in - if value.translation.width < .zero { - floatingPlayerOffsetX = value.translation.width - } - - if abs(floatingPlayerOffsetX) > swipeThreshold, !isSwipping { - isSwipping = true - } - } - .onEnded { _ in - if abs(floatingPlayerOffsetX) > swipeThreshold, isSwipping { - playerViewModel.destroyPlayerAndQueue() - } - - self.floatingPlayerOffsetX = .zero - self.isSwipping = false - } + DragGesture() + .onChanged { value in + if value.translation.width < .zero { + floatingPlayerOffsetX = value.translation.width + } + + if abs(floatingPlayerOffsetX) > swipeThreshold, !isSwipping { + isSwipping = true + } + } + .onEnded { _ in + if abs(floatingPlayerOffsetX) > swipeThreshold, isSwipping { + withAnimation(.easeOut(duration: 0.25)) { + self.floatingPlayerOffsetX = -UIScreen.main.bounds.width + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + playerViewModel.destroyPlayerAndQueue() + self.floatingPlayerOffsetX = .zero + } + + } else { + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)){ + self.floatingPlayerOffsetX = .zero + + } + } + self.isSwipping = false + } ) } } @@ -352,7 +374,18 @@ struct ContentView: View { } } .onAppear { - PlaybackCoordinator.shared.attach(playerViewModel: playerViewModel) + PlaybackCoordinator.shared.attach(playerViewModel: playerViewModel) + } + .onReceive(NotificationCenter.default.publisher(for: + UIResponder.keyboardWillShowNotification)) { notification in + if let userInfo = notification.userInfo, + let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + keyboardHeight = keyboardFrame.height + } + } + .onReceive(NotificationCenter.default.publisher(for: + UIResponder.keyboardWillHideNotification)) { _ in + keyboardHeight = 0 } } } From 2e14d6128b81538379d80393db9e126df01f25d8 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 20:58:28 -0400 Subject: [PATCH 02/10] Animations for failing to dismiss and dismissing the PlayerView. Some adjustments to the existing animation. --- flo/ContentView.swift | 2 +- flo/PlayerView.swift | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/flo/ContentView.swift b/flo/ContentView.swift index 93596c5..5b18e4c 100644 --- a/flo/ContentView.swift +++ b/flo/ContentView.swift @@ -300,7 +300,7 @@ struct ContentView: View { PlayerView(isExpanded: $isPlayerExpanded, viewModel: playerViewModel) .ignoresSafeArea() .offset(y: isPlayerExpanded ? 0 : offScreenY) - .animation(.spring(duration: 0.2), value: isPlayerExpanded) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isPlayerExpanded) } if !isPadSidebar { diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 6b77ac7..032cef3 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -195,6 +195,9 @@ struct PlayerView: View { } } .offset(y: offset.height) + .onChange(of: isExpanded) { expanded in + if expanded {offset = .zero} + } .gesture( DragGesture() .onChanged { gesture in @@ -208,8 +211,11 @@ struct PlayerView: View { .onEnded { _ in if offset.height > size.height / 3 { isExpanded = false + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)){ + offset = .zero + } } - offset = .zero isDragging = false } ) From dbe02c34402e9587e82bdab0101c9bf6ea740380 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 20:59:54 -0400 Subject: [PATCH 03/10] Fix for the FloatingPlayerView sitting on top of the tab bar on Max devices. --- flo/ContentView.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/flo/ContentView.swift b/flo/ContentView.swift index 5b18e4c..7737e2e 100644 --- a/flo/ContentView.swift +++ b/flo/ContentView.swift @@ -308,13 +308,7 @@ struct ContentView: View { Spacer() if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { - let isSmallScreen = UIScreen.main.bounds.width <= 390 let isPad = UIDevice.current.userInterfaceIdiom == .pad - let bottomPadding: CGFloat = isSmallScreen ? 32 : 0 - let playerWidth: CGFloat? = - isPad - ? 720 - : (horizontalSizeClass == .regular ? 500 : nil) let playerCenterOffsetX = floatingPlayerContentCenterOffsetX( totalWidth: geometry.size.width ) @@ -322,12 +316,12 @@ struct ContentView: View { #if targetEnvironment(macCatalyst) 10 #else - isPad ? 0 : (40 + bottomPadding) + keyboardHeight > 0 ? -keyboardHeight + geometry.safeAreaInsets.bottom + 8: geometry.safeAreaInsets.bottom + 20 #endif }() FloatingPlayerView(viewModel: playerViewModel) - .frame(maxWidth: playerWidth ?? .infinity) + .padding(.horizontal, isPad ? 40 : 8) .padding(.bottom, playerBottomPadding) .opacity(playerViewModel.hasNowPlaying() ? 1 : 0) .offset( From 84574fdd872210ea7eea516ef6ea4dc2ddfaa426 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 21:02:32 -0400 Subject: [PATCH 04/10] Bottom button bar fixes. Makes airplay device name visible and pushes the buttons up above the home bar. Sits a bit further down on devices with a home button. Dynamic placement based on safe area. --- flo/PlayerView.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 032cef3..5a5d09f 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -394,10 +394,10 @@ struct PlayerView: View { bottomControlBar(showQueue: $showQueue) .padding(.top, 16) .padding(.horizontal, 18) - .padding(.bottom, max(bottomSafeInset, 12)) + .padding(.bottom, max(bottomSafeInset, 12) + 20) } - .frame(maxWidth: horizontalSizeClass == .regular ? 500 : .infinity) .frame(maxWidth: .infinity) + .padding(.horizontal, horizontalSizeClass == .regular ? 60 : 16) } @ViewBuilder @@ -442,10 +442,8 @@ struct PlayerView: View { .foregroundColor(.white) .customFont(.caption2) .fontWeight(.bold) - .lineLimit(2) - .multilineTextAlignment(.center) - .frame(maxWidth: 260) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) .offset(y: 13) } } From ea80dcb44f2f45f4c1dc90b93d8af9d0253eecf9 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 21:07:11 -0400 Subject: [PATCH 05/10] Swap the rectangle drag handle with a proper drag handle. Includes larger target area for grabbing to make it easier to use. --- flo/PlayerView.swift | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 5a5d09f..2027d59 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -48,10 +48,7 @@ struct PlayerView: View { HStack { Spacer() - Rectangle() - .foregroundColor(Color.gray.opacity(0.3)) - .frame(width: 50, height: 5) - .cornerRadius(30) + DragHandle(color: .gray.opacity(0.3)) .padding(.top) Spacer() @@ -240,11 +237,8 @@ struct PlayerView: View { bottomSafeInset: CGFloat ) -> some View { VStack { - Rectangle() - .foregroundColor(Color.gray.opacity(0.8)) - .frame(width: 50, height: 5) - .cornerRadius(30) - .padding(.top, topSafeInset + 8) + DragHandle(color: .gray.opacity(0.8)) + .padding(.top, topSafeInset + 8) Spacer() let coverArtUrl = viewModel.getAlbumCoverArt() @@ -504,14 +498,15 @@ struct PlayerView: View { .blur(radius: 50, opaque: true) } } - } - - Rectangle().fill(.thinMaterial) - } else { - Rectangle().fill(Color("PlayerColor")) + .clipShape(.rect(topLeadingRadius: 48, topTrailingRadius: 48)) + .frame( + width: geometry.size.width, + height: geometry.size.height + 40, + alignment: .top + ) + .offset(x: 0, y: -20) + .environment(\.colorScheme, .dark) } - } - .environment(\.colorScheme, .dark) .ignoresSafeArea() } @@ -532,6 +527,16 @@ struct PlayerView: View { } } +struct DragHandle: View { + let color: Color + var body: some View { + RoundedRectangle(cornerRadius: 2.5) + .fill(color) + .frame(width: 50, height: 5) + .frame(width: 80, height: 44) + .contentShape(Rectangle()) + } +} struct PlayerView_previews: PreviewProvider { @StateObject static var viewModel = PlayerViewModel() @State static var isExpanded: Bool = true From 99c109a0e8d5d4523a59daa74a3aefc795a7ece7 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 21:14:55 -0400 Subject: [PATCH 06/10] Remaining logic for rounding the PlayerView corners (included in changes from commit ea80dcb) --- flo/PlayerView.swift | 51 +++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 2027d59..c0dc105 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -30,7 +30,12 @@ struct PlayerView: View { let bottomSafeInset = proxy.safeAreaInsets.bottom let imageSize: CGFloat = horizontalSizeClass == .regular ? min(400, size.width * 0.4) : 300 let isIPadPortrait = UIDevice.current.userInterfaceIdiom == .pad && size.height > size.width - let queueSheetHeight = isIPadPortrait ? min(700, max(500, size.height * 0.62)) : 500 + let queueSheetHeight: CGFloat = { + if isIPadPortrait { + return min(700, max(500, size.height * 0.62)) + } + return size.height * 0.7 + }() ZStack { playerBackground() @@ -41,9 +46,7 @@ struct PlayerView: View { ZStack(alignment: .topLeading) { Color(.systemBackground) .ignoresSafeArea() - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) + VStack(alignment: .leading) { HStack { Spacer() @@ -138,7 +141,7 @@ struct PlayerView: View { } } } - }.padding(.bottom, 60) + }.padding(.bottom, 40) } } .gesture( @@ -482,21 +485,29 @@ struct PlayerView: View { @ViewBuilder private func playerBackground() -> some View { - ZStack { - if UserDefaultsManager.playerBackground == PlayerBackground.translucent { - if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { - Image(uiImage: image) - .resizable() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .blur(radius: 50, opaque: true) - } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image - .resizable() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .blur(radius: 50, opaque: true) - } + GeometryReader { geometry in + ZStack { + if UserDefaultsManager.playerBackground == PlayerBackground.translucent { + if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { + Image(uiImage: image) + .resizable() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .blur(radius: 50, opaque: true) + } else { + LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in + if let image = state.image { + image + .resizable() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .blur(radius: 50, opaque: true) + } + } + } + + Rectangle().fill(.thinMaterial) + } else { + Rectangle().fill(Color("PlayerColor")) + } } .clipShape(.rect(topLeadingRadius: 48, topTrailingRadius: 48)) .frame( From a43e15cf199f4659592524464020d729fe333a19 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 21:15:46 -0400 Subject: [PATCH 07/10] Adjusts swipe distance for the PlayerView --- flo/PlayerView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index c0dc105..0ae8164 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -209,7 +209,7 @@ struct PlayerView: View { } } .onEnded { _ in - if offset.height > size.height / 3 { + if offset.height > size.height / 6 { isExpanded = false } else { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)){ From cad0445246f6b8ab9ba7712dada435e89cc3bdc8 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 21:17:21 -0400 Subject: [PATCH 08/10] Dynamic scaling and placement for various devices. Improves consistency on different device sizes. --- flo/PlayerView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 0ae8164..9dd8a66 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -283,7 +283,7 @@ struct PlayerView: View { } } - Spacer().frame(height: horizontalSizeClass == .regular ? 44 : 36) + Spacer().frame(height: size.height * 0.05) VStack(alignment: .center, spacing: 10) { Text(viewModel.nowPlaying.songName ?? "") @@ -299,7 +299,7 @@ struct PlayerView: View { .multilineTextAlignment(.center) .lineLimit(2) } - .padding(.horizontal, 30) + .padding(.horizontal, horizontalSizeClass == .regular ? 60 : 20) Spacer() @@ -311,7 +311,7 @@ struct PlayerView: View { viewModel.isPlaying ? viewModel.pause() : viewModel.play() } label: { Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 50)) + .font(.system(size: imageSize * 0.15)) } .foregroundColor(viewModel.isMediaLoading ? .gray : .white) .disabled(viewModel.isMediaLoading) @@ -361,7 +361,7 @@ struct PlayerView: View { Text(viewModel.isLiveRadio ? "" : viewModel.currentTimeString) .foregroundColor(.white) .customFont(.caption2) - .frame(width: 60, alignment: .leading) + .frame(minWidth: 44, idealWidth: 60, maxWidth: 80, alignment: .leading) Spacer() From 4931d8d98b00a795d64fa86aea7f109b5dedae9a Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 21:18:23 -0400 Subject: [PATCH 09/10] Larger target for the song position icon. Increases the size slightly. --- flo/PlayerView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 9dd8a66..143ca20 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -531,7 +531,7 @@ struct PlayerView: View { Capsule() .fill(Color.white) - .frame(width: geometry.size.width, height: 4) + .frame(width: geometry.size.width, height: 5) } } .frame(height: 20) From c858e4c231b10c87b90e5c144910111eba63eda1 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 21:43:51 -0400 Subject: [PATCH 10/10] Adjust PlayerView height and offset values Fixes the white corners in screenshots by extending the background a bit further --- flo/PlayerView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 143ca20..9ac5725 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -512,10 +512,10 @@ struct PlayerView: View { .clipShape(.rect(topLeadingRadius: 48, topTrailingRadius: 48)) .frame( width: geometry.size.width, - height: geometry.size.height + 40, + height: geometry.size.height + 60, alignment: .top ) - .offset(x: 0, y: -20) + .offset(x: 0, y: -30) .environment(\.colorScheme, .dark) } .ignoresSafeArea()