diff --git a/flo/ContentView.swift b/flo/ContentView.swift index ac12301..7737e2e 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 { @@ -288,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 { @@ -296,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 ) @@ -310,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( @@ -327,24 +333,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 +368,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 } } } diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 6b77ac7..9ac5725 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,17 +46,12 @@ struct PlayerView: View { ZStack(alignment: .topLeading) { Color(.systemBackground) .ignoresSafeArea() - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) + VStack(alignment: .leading) { 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() @@ -141,7 +141,7 @@ struct PlayerView: View { } } } - }.padding(.bottom, 60) + }.padding(.bottom, 40) } } .gesture( @@ -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 @@ -206,10 +209,13 @@ 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)){ + offset = .zero + } } - offset = .zero isDragging = false } ) @@ -234,11 +240,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() @@ -280,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 ?? "") @@ -296,7 +299,7 @@ struct PlayerView: View { .multilineTextAlignment(.center) .lineLimit(2) } - .padding(.horizontal, 30) + .padding(.horizontal, horizontalSizeClass == .regular ? 60 : 20) Spacer() @@ -308,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) @@ -358,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() @@ -388,10 +391,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 @@ -436,10 +439,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) } } @@ -484,30 +485,39 @@ 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")) + } } - } - - Rectangle().fill(.thinMaterial) - } else { - Rectangle().fill(Color("PlayerColor")) + .clipShape(.rect(topLeadingRadius: 48, topTrailingRadius: 48)) + .frame( + width: geometry.size.width, + height: geometry.size.height + 60, + alignment: .top + ) + .offset(x: 0, y: -30) + .environment(\.colorScheme, .dark) } - } - .environment(\.colorScheme, .dark) .ignoresSafeArea() } @@ -521,13 +531,23 @@ struct PlayerView: View { Capsule() .fill(Color.white) - .frame(width: geometry.size.width, height: 4) + .frame(width: geometry.size.width, height: 5) } } .frame(height: 20) } } +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