import SwiftUI import UIKit import Combine struct MainTabView: View { @EnvironmentObject var audioPlayer: AudioPlayer @ObservedObject private var debugLogger = DebugLogger.shared @State private var selectedTab = 0 @State private var navigateToPlaylistId: String? @State private var navigateToAlbumId: String? @State private var navigateToArtistId: String? @State var showNowPlaying = false // Debug panel (docked) @State private var debugPanelHeight: CGFloat = 250 @State private var debugDragOffset: CGFloat = 0 // Debug PiP (floating) @State private var debugPipMode = false @State private var pipPosition: CGPoint = CGPoint(x: 16, y: 100) @State private var pipDragOffset: CGSize = .zero @State private var pipSize: CGSize = CGSize(width: 320, height: 220) @State private var pipCollapsed = false @State private var pipResizeStart: CGSize = .zero private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { ZStack { // Main tab content ZStack(alignment: .bottom) { TabView(selection: $selectedTab) { MyMusicView( navigateToPlaylistId: $navigateToPlaylistId, navigateToAlbumId: $navigateToAlbumId, navigateToArtistId: $navigateToArtistId ) .tabItem { Image(systemName: "music.note") Text("My Music") } .tag(0) SearchView() .tabItem { Image(systemName: "magnifyingglass") Text("Search") } .tag(1) RadioView() .tabItem { Image(systemName: "antenna.radiowaves.left.and.right") Text("Radio") } .tag(2) DownloadsView() .tabItem { Image(systemName: "arrow.down.circle") Text("Downloads") } .tag(3) SettingsView() .tabItem { Image(systemName: "gear") Text("Settings") } .tag(4) } .tint(accentPink) // Debug panel (docked) — only when enabled and NOT in PiP mode if debugLogger.isEnabled && !debugPipMode { debugPanel .transition(.move(edge: .bottom).combined(with: .opacity)) .zIndex(2) } if audioPlayer.currentSong != nil { let effectiveDebugHeight = max(debugPanelHeight - debugDragOffset, 120) let showDocked = debugLogger.isEnabled && !debugPipMode MiniPlayerBar(showNowPlaying: $showNowPlaying) .padding(.bottom, showDocked ? 49 + effectiveDebugHeight : 49) .animation(.easeInOut(duration: 0.25), value: showDocked) .zIndex(3) } } // Now Playing overlay — always rendered, positioned via offset // This avoids re-layout animation when appearing if audioPlayer.currentSong != nil { NowPlayingView(isPresented: $showNowPlaying) .zIndex(1) .offset(y: showNowPlaying ? 0 : 1500) .allowsHitTesting(showNowPlaying) } // Debug PiP (floating) — above everything except Now Playing if debugLogger.isEnabled && debugPipMode { debugPipView .zIndex(showNowPlaying ? 0 : 5) .opacity(showNowPlaying ? 0 : 1) } } .onReceive(NotificationCenter.default.publisher(for: .navigateToPlaylist)) { notif in if let playlistId = notif.userInfo?["playlistId"] as? String { selectedTab = 0 withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { navigateToPlaylistId = playlistId } } } .onReceive(NotificationCenter.default.publisher(for: .navigateToAlbum)) { notif in if let albumId = notif.userInfo?["albumId"] as? String { selectedTab = 0 withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { navigateToAlbumId = albumId } } } .onReceive(NotificationCenter.default.publisher(for: .navigateToArtist)) { notif in if let artistId = notif.userInfo?["artistId"] as? String { selectedTab = 0 withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { navigateToArtistId = artistId } } } } // MARK: - Debug Panel private var debugPanel: some View { VStack(spacing: 0) { // Drag handle + header VStack(spacing: 4) { Capsule() .fill(Color.white.opacity(0.4)) .frame(width: 36, height: 4) .padding(.top, 8) HStack { Image(systemName: "ladybug.fill") .font(.system(size: 12)) .foregroundColor(.red) Text("Debug Console") .font(.system(size: 13, weight: .semibold)) .foregroundColor(.white) Text("\(DebugLogger.shared.entries.count)") .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundColor(.gray) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.white.opacity(0.1)) .cornerRadius(8) Spacer() Button(action: { DebugLogger.shared.clear() }) { Text("Clear") .font(.system(size: 12, weight: .medium)) .foregroundColor(.red.opacity(0.8)) } Button(action: { withAnimation(.easeInOut(duration: 0.3)) { debugPipMode = true } }) { Image(systemName: "pip") .font(.system(size: 14)) .foregroundColor(.white.opacity(0.6)) } Button(action: { debugLogger.isEnabled = false }) { Image(systemName: "xmark.circle.fill") .font(.system(size: 16)) .foregroundColor(.gray) } } .padding(.horizontal, 14) .padding(.bottom, 6) } .background(Color(white: 0.1)) .gesture( DragGesture() .onChanged { value in debugDragOffset = value.translation.height } .onEnded { value in let newHeight = debugPanelHeight - value.translation.height withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { debugDragOffset = 0 debugPanelHeight = min(max(newHeight, 120), 500) } } ) Divider().background(Color.white.opacity(0.15)) // Log entries ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { ForEach(DebugLogger.shared.entries) { entry in debugLogRow(entry) .id(entry.id) } } .padding(.horizontal, 10) .padding(.vertical, 4) } .onChange(of: DebugLogger.shared.entries.count) { _, _ in if let last = DebugLogger.shared.entries.last { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(last.id, anchor: .bottom) } } } } } .frame(height: max(debugPanelHeight - debugDragOffset, 120)) .background(Color(white: 0.06)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .shadow(color: .black.opacity(0.4), radius: 8, y: -2) .padding(.horizontal, 4) .padding(.bottom, 49) // above tab bar } private func debugLogRow(_ entry: DebugLogger.LogEntry) -> some View { let color: Color = { switch entry.category { case "Watch": return .cyan case "Audio": return .green case "FFT": return .orange case "Network": return .yellow case "Error": return .red case "Radio": return .purple default: return .gray } }() return HStack(alignment: .top, spacing: 6) { Text(entry.category) .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(color) .padding(.horizontal, 3) .padding(.vertical, 1) .background(color.opacity(0.15)) .cornerRadius(3) Text(entry.message) .font(.system(size: 10, design: .monospaced)) .foregroundColor(.white.opacity(0.85)) .lineLimit(3) } .padding(.vertical, 3) } // MARK: - Debug PiP (Floating) private var debugPipView: some View { let currentPos = CGPoint( x: pipPosition.x + pipDragOffset.width, y: pipPosition.y + pipDragOffset.height ) return VStack(spacing: 0) { // PiP header — always visible HStack(spacing: 8) { Image(systemName: "ladybug.fill") .font(.system(size: 10)) .foregroundColor(.red) Text("Debug") .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundColor(.white) Text("\(DebugLogger.shared.entries.count)") .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundColor(.gray) Spacer() Button(action: { DebugLogger.shared.clear() }) { Image(systemName: "trash") .font(.system(size: 11)) .foregroundColor(.red.opacity(0.7)) } // Collapse/expand Button(action: { withAnimation(.easeInOut(duration: 0.2)) { pipCollapsed.toggle() } }) { Image(systemName: pipCollapsed ? "chevron.down" : "chevron.up") .font(.system(size: 11, weight: .semibold)) .foregroundColor(.white.opacity(0.6)) } // Dock back Button(action: { withAnimation(.easeInOut(duration: 0.3)) { debugPipMode = false } }) { Image(systemName: "dock.rectangle") .font(.system(size: 11)) .foregroundColor(.white.opacity(0.6)) } Button(action: { withAnimation(.easeInOut(duration: 0.2)) { debugLogger.isEnabled = false debugPipMode = false } }) { Image(systemName: "xmark") .font(.system(size: 10, weight: .bold)) .foregroundColor(.gray) } } .padding(.horizontal, 10) .padding(.vertical, 7) .background(Color(white: 0.12)) .gesture( DragGesture() .onChanged { value in pipDragOffset = value.translation } .onEnded { value in pipPosition = CGPoint( x: pipPosition.x + value.translation.width, y: pipPosition.y + value.translation.height ) pipDragOffset = .zero } ) // Log content — hidden when collapsed if !pipCollapsed { Divider().background(Color.white.opacity(0.1)) ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { ForEach(DebugLogger.shared.entries) { entry in debugLogRow(entry) .id(entry.id) } } .padding(.horizontal, 8) .padding(.vertical, 4) } .onChange(of: DebugLogger.shared.entries.count) { _, _ in if let last = DebugLogger.shared.entries.last { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(last.id, anchor: .bottom) } } } } .frame(height: pipCollapsed ? 0 : pipSize.height - 32) // Resize handle HStack { Spacer() Image(systemName: "arrow.up.left.and.arrow.down.right") .font(.system(size: 9)) .foregroundColor(.white.opacity(0.3)) .padding(6) .gesture( DragGesture() .onChanged { value in if pipResizeStart == .zero { pipResizeStart = pipSize } let newW = max(200, pipResizeStart.width + value.translation.width) let newH = max(100, pipResizeStart.height + value.translation.height) pipSize = CGSize(width: min(newW, 500), height: min(newH, 500)) } .onEnded { _ in pipResizeStart = .zero } ) } .frame(height: 18) .background(Color(white: 0.08)) } } .frame(width: pipSize.width) .background(Color(white: 0.06).opacity(0.95)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.white.opacity(0.08), lineWidth: 0.5) ) .shadow(color: .black.opacity(0.5), radius: 12, y: 4) .position(x: currentPos.x + pipSize.width / 2, y: currentPos.y + (pipCollapsed ? 16 : pipSize.height / 2)) } } extension Notification.Name { static let navigateToPlaylist = Notification.Name("navigateToPlaylist") static let navigateToAlbum = Notification.Name("navigateToAlbum") static let navigateToArtist = Notification.Name("navigateToArtist") } // MARK: - Mini Player Bar with scrubbable progress struct MiniPlayerBar: View { @EnvironmentObject var audioPlayer: AudioPlayer @StateObject private var colorExtractor = AlbumColorExtractor.shared @Binding var showNowPlaying: Bool @State private var isScrubbing = false @State private var scrubPosition: Double = 0 @State private var playbackTime: TimeInterval = 0 @State private var playbackDuration: TimeInterval = 0 private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) // Poll currentTime at our own pace — doesn't trigger parent view redraws private let timePoller = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect() private var displayProgress: Double { if isScrubbing { return scrubPosition } guard playbackDuration > 0 else { return 0 } return min(playbackTime / playbackDuration, 1.0) } var body: some View { VStack(spacing: 0) { // Scrubbable progress bar at top — uses album color, generous touch target GeometryReader { geo in ZStack(alignment: .leading) { Rectangle() .fill(Color.white.opacity(0.1)) .frame(height: isScrubbing ? 8 : 3) Rectangle() .fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink) .frame( width: geo.size.width * displayProgress, height: isScrubbing ? 8 : 3 ) if isScrubbing { Circle() .fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink) .frame(width: 16, height: 16) .offset(x: geo.size.width * displayProgress - 8, y: -1) } } .frame(maxHeight: .infinity) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in isScrubbing = true scrubPosition = min(max(value.location.x / geo.size.width, 0), 1) } .onEnded { value in let pct = min(max(value.location.x / geo.size.width, 0), 1) audioPlayer.seekToPercent(pct) isScrubbing = false } ) } .frame(height: isScrubbing ? 20 : 14) // Generous touch target even when not scrubbing .animation(.easeInOut(duration: 0.15), value: isScrubbing) // Player content ZStack(alignment: .center) { // Visualizer behind controls — paused when full NowPlaying is open if VisualizerSettings.shared.enabled && VisualizerSettings.shared.miniPlayerEnabled && !showNowPlaying { CompactVisualizerView( isPlaying: audioPlayer.isPlaying, accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink, height: VisualizerSettings.shared.miniPlayerHeight ) .offset(y: 10) .opacity(VisualizerSettings.shared.miniOpacity) .allowsHitTesting(false) } HStack(spacing: 12) { // Cover art Group { if let song = audioPlayer.currentSong, (song.album == "Radio" || audioPlayer.isRadioStream), let img = RadioCoverStore.shared.loadCover(for: song.id) { Image(uiImage: img) .resizable() .aspectRatio(contentMode: .fill) } else { AsyncCoverArt( coverArtId: audioPlayer.currentSong?.coverArt, size: 48 ) } } .frame(width: 44, height: 44) .cornerRadius(6) .shadow(radius: 3) VStack(alignment: .leading, spacing: 1) { Text(audioPlayer.currentSong?.title ?? "") .font(.system(size: 14, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) Text(audioPlayer.currentSong?.artist ?? "") .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) .lineLimit(1) } Spacer() Button(action: { audioPlayer.previous() }) { Image(systemName: "backward.fill") .font(.system(size: 16)) .foregroundColor(.white) } .frame(width: 36, height: 44) Button(action: { audioPlayer.togglePlayPause() }) { Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 22)) .foregroundColor(.white) } .frame(width: 44, height: 44) Button(action: { audioPlayer.next() }) { Image(systemName: "forward.fill") .font(.system(size: 16)) .foregroundColor(.white) } .frame(width: 36, height: 44) } .padding(.horizontal, 12) .padding(.vertical, 8) } .frame(height: 60) .contentShape(Rectangle()) .onTapGesture { if !isScrubbing { dismissKeyboard() withAnimation(.easeInOut(duration: 0.35)) { showNowPlaying = true } } } } .background(.ultraThinMaterial) .background( colorExtractor.isLoaded ? colorExtractor.primaryColor.opacity(0.15) : Color.clear ) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .shadow(color: .black.opacity(0.3), radius: 8, y: 4) .padding(.horizontal, 8) .onReceive(timePoller) { _ in playbackTime = audioPlayer.currentTime playbackDuration = audioPlayer.duration } } } // MARK: - Keyboard Dismiss /// Dismiss keyboard from anywhere func dismissKeyboard() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } /// UIKit tap recognizer that dismisses keyboard without blocking other touches. /// Added once to the key window — fires alongside all gestures. private class KeyboardDismissTapRecognizer: UITapGestureRecognizer, UIGestureRecognizerDelegate { override init(target: Any?, action: Selector?) { super.init(target: target, action: action) cancelsTouchesInView = false delegate = self } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { true // Never block other gestures } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { // Don't fire when tapping on text inputs themselves !(touch.view is UITextField || touch.view is UITextView) } } /// Helper target for the tap gesture — calls endEditing on the window private class KeyboardDismissTarget: NSObject { @objc func dismiss() { dismissKeyboard() } } /// View modifier that installs a one-time keyboard dismiss gesture on the window. struct KeyboardDismissible: ViewModifier { // Held as static so the target isn't deallocated private static let target = KeyboardDismissTarget() func body(content: Content) -> some View { content.onAppear { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first else { return } // Only add once let alreadyAdded = window.gestureRecognizers?.contains(where: { $0 is KeyboardDismissTapRecognizer }) ?? false if !alreadyAdded { let tap = KeyboardDismissTapRecognizer( target: KeyboardDismissible.target, action: #selector(KeyboardDismissTarget.dismiss) ) window.addGestureRecognizer(tap) } } } } extension View { func dismissKeyboardOnTap() -> some View { modifier(KeyboardDismissible()) } }