From 4f9b5a9aa5143cdf8711e05b04e3eae5fd6612e3 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Mon, 10 Aug 2026 20:54:04 -0400 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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() From 8ead25c9a031d23c052e728a4609964adb9078ad Mon Sep 17 00:00:00 2001 From: myhaksown Date: Wed, 12 Aug 2026 01:58:40 -0400 Subject: [PATCH 11/16] Redesigned LyricView to run on it's own with toggle, persistance when dismissed and called back, play controls, and gradient blur. Allows scrolling, pause to unblur, and return to current lyrics. Added the ability to identify when there is a server error vs no lyrics for a song. Tested on iPhone 13 Pro Max, untested on iPad or iPhones of other sizes. Not tested on iOS 16 or 17. Compatibility should be iOS 18+. Certain features will need to be disabled for compatibility on iOS 16 and 17 such as all blur effects. Likely need to be cleaned up. A lot of trial and error was involved in the changes. --- flo/PlayerViewModel.swift | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 4b85f37..bceb74a 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -204,8 +204,8 @@ class PlayerViewModel: ObservableObject { try? AVAudioSession.sharedInstance().setActive(true) - self.resetLyrics() - self.checkStarredStatus() + self.resetLyrics(closeLyricsMode: false) + self.checkStarredStatus() if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) @@ -700,12 +700,23 @@ class PlayerViewModel: ObservableObject { try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } - func resetLyrics() { - self.lyrics = [] - self.currentLyricsLineIndex = -1 - self.lyricsError = nil - self.isLyricsMode = false - } + func resetLyrics(closeLyricsMode: Bool = true) { + self.lyrics = [] + self.currentLyricsLineIndex = -1 + self.lyricsError = nil + + if closeLyricsMode { + self.isLyricsMode = false + } + } + + func openLyricsMode() { + guard hasNowPlaying(), !isLiveRadio else { return } + + withAnimation(.spring(duration: 0.3)) { + isLyricsMode = true + } + } func fetchLyrics() { // just in case From ba18ca1f893acefce1aa896b0b14c285d3f9a982 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Wed, 12 Aug 2026 02:02:23 -0400 Subject: [PATCH 12/16] Remaining changes required for commit 8ead25c --- flo/LyricsView.swift | 315 +++++++++++++++++++++++++++++++------------ flo/PlayerView.swift | 17 ++- 2 files changed, 240 insertions(+), 92 deletions(-) diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift index 7f2cccd..1250980 100644 --- a/flo/LyricsView.swift +++ b/flo/LyricsView.swift @@ -8,21 +8,79 @@ import NukeUI import SwiftUI +@MainActor +class ScrollState: ObservableObject { + @Published var isScrolling = false + private var lastOffset: CGFloat = .zero + private var scrollTask: Task? + private var isAutoScrolling = false + + func setAutoScrolling(_ value: Bool) { + isAutoScrolling = value + } + + func handleScroll(offset: CGFloat) { + guard !isAutoScrolling else { return } + + guard abs(offset - lastOffset) > 1.0 else { return } + lastOffset = offset + + scrollTask?.cancel() + + if !isScrolling { + withAnimation(.easeOut(duration: 0.15)) { + isScrolling = true + } + } + + scrollTask = Task { + try? await Task.sleep(nanoseconds: 800_000_000) + guard !Task.isCancelled else { return } + + self.isScrolling = false + } + } +} + struct LyricsView: View { - @ObservedObject var viewModel: PlayerViewModel - @Binding var showQueue: Bool + @ObservedObject var viewModel: PlayerViewModel + @Binding var showQueue: Bool + @Binding var isExpanded: Bool + @GestureState private var handleDragOffset: CGSize = .zero + + @StateObject private var scrollState = ScrollState() - let imageSize: CGFloat - let topSafeInset: CGFloat - let bottomSafeInset: CGFloat + let imageSize: CGFloat + let topSafeInset: CGFloat + let bottomSafeInset: CGFloat private var isPlainLyrics: Bool { return viewModel.lyrics.count == 1 } - var body: some View { - VStack(spacing: 0) { - HStack(spacing: 16) { + var body: some View { + VStack(spacing: 0) { + HStack { + Spacer() + DragHandle(color: .gray.opacity(0.3)) + Spacer() + } + .padding(.top, topSafeInset) + .highPriorityGesture( + DragGesture(coordinateSpace: .global) + .updating($handleDragOffset) { value, state, _ in + if value.translation.height > 0 { + state = value.translation + } + } + .onEnded { value in + if value.translation.height > UIScreen.main.bounds.height / 6 { + isExpanded = false + } + } + ) + + HStack(spacing: 16) { Group { if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { Image(uiImage: image) @@ -56,22 +114,10 @@ struct LyricsView: View { .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) - - Button { - viewModel.toggleLyricsMode() - } label: { - Image(systemName: "chevron.down") - .font(.title3.weight(.semibold)) - .foregroundColor(.white) - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background(.white.opacity(0.15)) - .clipShape(Capsule()) - .shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3) - } } + .padding(.horizontal, 30) - .padding(.top, topSafeInset + 8) + .padding(.top, 8) .padding(.bottom, 16) .onTapGesture { viewModel.toggleLyricsMode() @@ -99,53 +145,135 @@ struct LyricsView: View { Spacer() } else { ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: false) { - LazyVStack(spacing: 20) { - ForEach(Array(viewModel.lyrics.enumerated()), id: \.element.id) { index, line in - LyricLineView( - text: line.text, - isCurrentLine: index == viewModel.currentLyricsLineIndex, - isPastLine: index < viewModel.currentLyricsLineIndex, - isPlainLyrics: isPlainLyrics + if #available(iOS 18.0, *) { + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 40) { + Spacer().frame(height: 20) + ForEach(Array(viewModel.lyrics.enumerated()), id: \.element.id) { index, line in + LyricLineView( + text: line.text, + distance: index - viewModel.currentLyricsLineIndex, + isPlainLyrics: isPlainLyrics, + suppressBlur: scrollState.isScrolling || !viewModel.isPlaying + ) + .id(index) + .onTapGesture { + guard !isPlainLyrics else { return } + + let progress = line.timestamp / viewModel.nowPlaying.duration + + viewModel.seek(to: progress) + viewModel.play() + } + } + + Spacer().frame(height: 250) + } + .padding(.horizontal, 30) + } + .onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentOffset.y + } action: { _, newOffset in + scrollState.handleScroll(offset: newOffset) + } + .mask( + LinearGradient( + stops: [ + .init(color: .clear, location: 0.0), + .init(color: .black, location: 0.08), + .init(color: .black, location: 0.92), + .init(color: .clear, location: 1.0), + ], + startPoint: .top, + endPoint: .bottom + ) ) - .id(index) - .onTapGesture { + .onAppear { guard !isPlainLyrics else { return } + guard viewModel.currentLyricsLineIndex >= 0 else { return } - let progress = line.timestamp / viewModel.nowPlaying.duration - - viewModel.seek(to: progress) - viewModel.play() + scrollState.setAutoScrolling(true) + proxy.scrollTo(viewModel.currentLyricsLineIndex, anchor: UnitPoint(x: 0.5, y: 0.4)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + scrollState.setAutoScrolling(false) + } } - } + .onChange(of: viewModel.currentLyricsLineIndex) { newIndex in + guard !isPlainLyrics, newIndex >= 0, !scrollState.isScrolling else { return } - Spacer().frame(height: 250) - } - .padding(.horizontal, 30) - } - .onAppear { - guard !isPlainLyrics else { return } - guard viewModel.currentLyricsLineIndex >= 0 else { return } - - proxy.scrollTo(viewModel.currentLyricsLineIndex, anchor: .center) - } - .onChange(of: viewModel.currentLyricsLineIndex) { newIndex in - guard !isPlainLyrics else { return } - guard newIndex >= 0 else { return } - - withAnimation(.easeInOut(duration: 0.5)) { - proxy.scrollTo(newIndex, anchor: .center) + scrollState.setAutoScrolling(true) + withAnimation(.easeInOut(duration: 0.5)) { + proxy.scrollTo(newIndex, anchor: UnitPoint(x: 0.5, y: 0.4)) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + scrollState.setAutoScrolling(false) + } + } + } else { + // Fallback on earlier versions } - } } } Spacer() - VStack(spacing: 0) { - HStack(spacing: 0) { - Button { - viewModel.toggleLyricsMode() + VStack(spacing: 10) { + PlayerCustomSlider( + isMediaLoading: viewModel.isMediaLoading, + isSeeking: $viewModel.isSeeking, + value: $viewModel.progress, + range: 0...1 + ) { newValue in + viewModel.seek(to: newValue) + } + + HStack { + Text(viewModel.currentTimeString) + .foregroundColor(.white) + .customFont(.caption2) + + Spacer() + + Text(viewModel.totalTimeString) + .foregroundColor(.white) + .customFont(.caption2) + } + } + .padding(.horizontal, 30) + + HStack(spacing: 50) { + Button { + viewModel.prevSong() + } label: { + Image(systemName: "backward.fill") + .font(.title2) + .foregroundColor(.white) + } + + Button { + viewModel.isPlaying ? viewModel.pause() : viewModel.play() + } label: { + Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 38)) + .foregroundColor(.white) + } + .disabled(viewModel.isMediaLoading) + .opacity(viewModel.isMediaLoading ? 0.4 : 1) + + Button { + viewModel.nextSong() + } label: { + Image(systemName: "forward.fill") + .font(.title2) + .foregroundColor(.white) + } + } + .padding(.top, 14) + + VStack(spacing: 0) { + HStack(spacing: 0) { + Button { + viewModel.toggleLyricsMode() } label: { Image(systemName: "quote.bubble.fill") .font(.title2) @@ -177,10 +305,8 @@ struct LyricsView: 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) } } @@ -217,33 +343,52 @@ struct LyricsView: View { } .frame(width: 44, height: 44) } - .padding(.horizontal, 18) - .padding(.top, 10) - .padding(.bottom, max(bottomSafeInset, 12)) + .padding(.horizontal, 34) + .padding(.top, 32) + .padding(.bottom, max(bottomSafeInset, 12) + 20) } } + .offset(y: handleDragOffset.height) + } } -struct LyricLineView: View { - let text: String - - let isCurrentLine: Bool - let isPastLine: Bool - let isPlainLyrics: Bool - - var body: some View { - Text(text) - .foregroundColor( - isCurrentLine ? .white : (isPastLine ? .white.opacity(0.3) : .white.opacity(0.5)) - ) - .customFont(.title) - .fontWeight(.semibold) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .lineSpacing(6) - .scaleEffect(isCurrentLine && !isPlainLyrics ? 1.03 : 1.0) - .animation(.easeInOut(duration: 0.3), value: isCurrentLine) - .opacity(isPlainLyrics ? 0.9 : 1.0) +private struct ScrollOffsetKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() } } + +struct LyricLineView: View { + let text: String + let distance: Int + let isPlainLyrics: Bool + let suppressBlur: Bool + + private var isCurrentLine: Bool { distance == 0 } + + private var blurRadius: CGFloat { + if isPlainLyrics || suppressBlur { return 0 } + let d = min(abs(distance), 6) + return CGFloat(d) * 1.5 + } + + var body: some View { + Text(text) + .foregroundColor( + isCurrentLine ? .white : (distance < 0 ? .white.opacity(0.3) : .white.opacity(0.5)) + ) + .customFont(.title) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .lineSpacing(6) + .scaleEffect(isCurrentLine && !isPlainLyrics ? 1.03 : 1.0) + .blur(radius: blurRadius) + //.animation(.easeInOut(duration: 0.3), value: distance) + //.animation(.easeInOut(duration: 0.3), value: suppressBlur) + .animation(.easeInOut(duration: 0.3), value: blurRadius) + .opacity(isPlainLyrics ? 0.9 : 1.0) + } +} diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 9ac5725..c86b47f 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -169,13 +169,16 @@ struct PlayerView: View { ZStack { if viewModel.isLyricsMode { - LyricsView( - viewModel: viewModel, - showQueue: $showQueue, - imageSize: imageSize, - topSafeInset: topSafeInset, - bottomSafeInset: bottomSafeInset - ).transition(.opacity.combined(with: .move(edge: .bottom))) + if viewModel.isLyricsMode { + LyricsView( + viewModel: viewModel, + showQueue: $showQueue, + isExpanded: $isExpanded, + imageSize: imageSize, + topSafeInset: topSafeInset, + bottomSafeInset: bottomSafeInset + ).transition(.opacity) + } } if !viewModel.isLyricsMode { From 335cd1041a0e5ff703511b6a2d815af9fe0897ed Mon Sep 17 00:00:00 2001 From: myhaksown Date: Wed, 12 Aug 2026 19:16:11 -0400 Subject: [PATCH 13/16] When a song fails to play from the navidrome server the song will show as paused and will skip after 2 seconds. This fixes the issue of the user getting stuck on a song when it fails to load. --- flo/PlayerViewModel.swift | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index bceb74a..5722373 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -54,7 +54,8 @@ class PlayerViewModel: ObservableObject { private var scrobbleThreshold = 0.5 private var hasTriggeredCache: Bool = false - + private var pendingSkipTask: Task? + var nowPlaying: QueueEntity { return self.queue[self.activeQueueIdx] } @@ -189,9 +190,18 @@ class PlayerViewModel: ObservableObject { } func setNowPlaying(playAudio: Bool = true) { - guard self.queue.indices.contains(self.activeQueueIdx) else { + pendingSkipTask?.cancel() + guard self.queue.indices.contains(self.activeQueueIdx) else { self.isMediaLoading = false self.isMediaFailed = true + self.isPlaying = false + let failureIdx = self.activeQueueIdx + pendingSkipTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + guard self.activeQueueIdx == failureIdx else { return } + self.nextSong() + } return } @@ -216,10 +226,17 @@ class PlayerViewModel: ObservableObject { let streamUrl = AlbumService.shared.getStreamUrl(id: songId) - guard let audioURL = URL(string: streamUrl), !streamUrl.isEmpty else { - self.isMediaLoading = false - self.isMediaFailed = true - + guard let audioURL = URL(string: streamUrl), !streamUrl.isEmpty else { + self.isMediaLoading = false + self.isMediaFailed = true + self.isPlaying = false + let failureIdx = self.activeQueueIdx + pendingSkipTask = Task {@MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + guard self.activeQueueIdx == failureIdx else { return } + self.nextSong() + } return } @@ -251,6 +268,14 @@ class PlayerViewModel: ObservableObject { case .failed: self.isMediaLoading = false self.isMediaFailed = true + self.isPlaying = false + let failureIdx = self.activeQueueIdx + pendingSkipTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + guard self.activeQueueIdx == failureIdx else { return } + self.nextSong() + } case .unknown: self.isMediaLoading = false @unknown default: @@ -682,7 +707,7 @@ class PlayerViewModel: ObservableObject { guard nextIdx < queue.count else { return nil } return nextIdx } - + func destroyPlayerAndQueue() { self.stop() self.progress = 0.0 From 816bbed5215c5c9b7b5dd12fb7e0ec7e6b784f88 Mon Sep 17 00:00:00 2001 From: myhaksown Date: Thu, 13 Aug 2026 18:58:50 -0400 Subject: [PATCH 14/16] Completes the changes for lyric view. Tested to work on iOS 16 thru 27 on iPads and iPhones of all sizes using an iPhone 13 Pro Max (iOS 27) and the simulator. --- flo/LyricsView.swift | 186 +++++++++++++++++++++++++++++++------------ 1 file changed, 135 insertions(+), 51 deletions(-) diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift index 1250980..008f96e 100644 --- a/flo/LyricsView.swift +++ b/flo/LyricsView.swift @@ -210,66 +210,143 @@ struct LyricsView: View { } } } else { - // Fallback on earlier versions + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 40) { + Spacer().frame(height: 20) + ForEach(Array(viewModel.lyrics.enumerated()), id: \.element.id) { index, line in + LyricLineView( + text: line.text, + distance: index - viewModel.currentLyricsLineIndex, + isPlainLyrics: isPlainLyrics, + suppressBlur: true + ) + .id(index) + .onTapGesture { + guard !isPlainLyrics else { return } + + let progress = line.timestamp / viewModel.nowPlaying.duration + + viewModel.seek(to: progress) + viewModel.play() + } + } + + Spacer().frame(height: 250) + } + .padding(.horizontal, 30) + } + .mask( + LinearGradient( + stops: [ + .init(color: .clear, location: 0.0), + .init(color: .black, location: 0.08), + .init(color: .black, location: 0.92), + .init(color: .clear, location: 1.0), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .onAppear { + guard !isPlainLyrics else { return } + guard viewModel.currentLyricsLineIndex >= 0 else { return } + + scrollState.setAutoScrolling(true) + proxy.scrollTo(viewModel.currentLyricsLineIndex, anchor: UnitPoint(x: 0.5, y: 0.4)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + scrollState.setAutoScrolling(false) + } + } + .onChange(of: viewModel.currentLyricsLineIndex) { newIndex in + guard !isPlainLyrics, newIndex >= 0, !scrollState.isScrolling else { return } + + scrollState.setAutoScrolling(true) + withAnimation(.easeInOut(duration: 0.5)) { + proxy.scrollTo(newIndex, anchor: UnitPoint(x: 0.5, y: 0.4)) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + scrollState.setAutoScrolling(false) + } + } } } } Spacer() - VStack(spacing: 10) { - PlayerCustomSlider( - isMediaLoading: viewModel.isMediaLoading, - isSeeking: $viewModel.isSeeking, - value: $viewModel.progress, - range: 0...1 - ) { newValue in - viewModel.seek(to: newValue) - } - - HStack { - Text(viewModel.currentTimeString) - .foregroundColor(.white) - .customFont(.caption2) - - Spacer() - - Text(viewModel.totalTimeString) - .foregroundColor(.white) - .customFont(.caption2) - } - } - .padding(.horizontal, 30) - - HStack(spacing: 50) { - Button { + HStack(spacing: 50) { + Button { viewModel.prevSong() - } label: { + } label: { Image(systemName: "backward.fill") - .font(.title2) - .foregroundColor(.white) - } - - Button { + .font(.title2) + .foregroundColor(.white) + } + + Button { viewModel.isPlaying ? viewModel.pause() : viewModel.play() - } label: { + } label: { Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 38)) - .foregroundColor(.white) - } - .disabled(viewModel.isMediaLoading) - .opacity(viewModel.isMediaLoading ? 0.4 : 1) - - Button { + .font(.system(size: 38)) + .foregroundColor(.white) + } + .disabled(viewModel.isMediaLoading) + .opacity(viewModel.isMediaLoading ? 0.4 : 1) + + Button { viewModel.nextSong() - } label: { + } label: { Image(systemName: "forward.fill") - .font(.title2) - .foregroundColor(.white) - } + .font(.title2) + .foregroundColor(.white) } + } .padding(.top, 14) + VStack { + if viewModel.isLiveRadio { + liveProgressBar() + } else { + PlayerCustomSlider( + isMediaLoading: viewModel.isMediaLoading, + isSeeking: $viewModel.isSeeking, value: $viewModel.progress, range: 0...1 + ) { newValue in + viewModel.seek(to: newValue) + } + } + + HStack { + Text(viewModel.isLiveRadio ? "" : viewModel.currentTimeString) + .foregroundColor(.white) + .customFont(.caption2) + .frame(minWidth: 44, idealWidth: 60, maxWidth: 80, alignment: .leading) + + Spacer() + + Text( + viewModel.isLiveRadio + ? "LIVE" + : (viewModel.isPlayFromSource + ? "\(viewModel.nowPlaying.suffix ?? "") \(viewModel.nowPlaying.bitRate.description)" + : "\(TranscodingSettings.targetFormat) \(UserDefaultsManager.maxBitRate)") + ) + .foregroundColor(.white) + .customFont(.caption2) + .fontWeight(.bold) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + + Text(viewModel.isLiveRadio ? "" : viewModel.totalTimeString) + .foregroundColor(.white) + .customFont(.caption2) + .frame(width: 60, alignment: .trailing) + } + } + .padding(.top, 30) + .padding(.horizontal, 30) + VStack(spacing: 0) { HStack(spacing: 0) { Button { @@ -353,11 +430,20 @@ struct LyricsView: View { } } -private struct ScrollOffsetKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() +@ViewBuilder +private func liveProgressBar() -> some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.gray.opacity(0.8)) + .frame(height: 5) + + Capsule() + .fill(Color.white) + .frame(width: geometry.size.width, height: 5) + } } + .frame(height: 20) } struct LyricLineView: View { @@ -386,8 +472,6 @@ struct LyricLineView: View { .lineSpacing(6) .scaleEffect(isCurrentLine && !isPlainLyrics ? 1.03 : 1.0) .blur(radius: blurRadius) - //.animation(.easeInOut(duration: 0.3), value: distance) - //.animation(.easeInOut(duration: 0.3), value: suppressBlur) .animation(.easeInOut(duration: 0.3), value: blurRadius) .opacity(isPlainLyrics ? 0.9 : 1.0) } From 116db5f72ba946ab92c26050956aae1c903b2b6e Mon Sep 17 00:00:00 2001 From: myhaksown Date: Thu, 13 Aug 2026 19:00:07 -0400 Subject: [PATCH 15/16] Fixes the PlayerView showing slightly on iPad Air 11 inch on iOS 26 when it is not pulled up. --- flo/ContentView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flo/ContentView.swift b/flo/ContentView.swift index 7737e2e..eddcf66 100644 --- a/flo/ContentView.swift +++ b/flo/ContentView.swift @@ -285,9 +285,9 @@ struct ContentView: View { GeometryReader { geometry in let offScreenY: CGFloat = { #if targetEnvironment(macCatalyst) - geometry.size.height + geometry.size.height + 20 #else - UIScreen.main.bounds.height + UIScreen.main.bounds.height + 20 #endif }() From 60c41cbc905c3ecd181b4b51a844d55e046db33a Mon Sep 17 00:00:00 2001 From: myhaksown Date: Thu, 13 Aug 2026 19:38:31 -0400 Subject: [PATCH 16/16] Fixes a minor bug with the drag handler. When dragging down the PlayerView background continued to show when on the LyricView. This syncronizes LyricView with PlayerView so they behave the same. --- flo/LyricsView.swift | 13 +++++++------ flo/PlayerView.swift | 1 + 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift index 008f96e..015120d 100644 --- a/flo/LyricsView.swift +++ b/flo/LyricsView.swift @@ -46,8 +46,7 @@ struct LyricsView: View { @ObservedObject var viewModel: PlayerViewModel @Binding var showQueue: Bool @Binding var isExpanded: Bool - @GestureState private var handleDragOffset: CGSize = .zero - + @Binding var dragOffset: CGSize @StateObject private var scrollState = ScrollState() let imageSize: CGFloat @@ -68,14 +67,18 @@ struct LyricsView: View { .padding(.top, topSafeInset) .highPriorityGesture( DragGesture(coordinateSpace: .global) - .updating($handleDragOffset) { value, state, _ in + .onChanged { value in if value.translation.height > 0 { - state = value.translation + dragOffset = value.translation } } .onEnded { value in if value.translation.height > UIScreen.main.bounds.height / 6 { isExpanded = false + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + dragOffset = .zero + } } } ) @@ -425,8 +428,6 @@ struct LyricsView: View { .padding(.bottom, max(bottomSafeInset, 12) + 20) } } - .offset(y: handleDragOffset.height) - } } diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index c86b47f..069e2cf 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -174,6 +174,7 @@ struct PlayerView: View { viewModel: viewModel, showQueue: $showQueue, isExpanded: $isExpanded, + dragOffset: $offset, imageSize: imageSize, topSafeInset: topSafeInset, bottomSafeInset: bottomSafeInset