NavidromeApp/iOS/Views/Common/MainTabView.swift

804 lines
33 KiB
Swift

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
// Dynamic Island morph
@Namespace private var playerNamespace
@State private var isDynamicIsland = false
@State private var dragOffset: CGFloat = 0
@State private var isDragging = 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 {
GeometryReader { rootGeo in
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)
.safeAreaInset(edge: .bottom) {
// Reserve space for MiniPlayerBar so content never gets obscured
if audioPlayer.currentSong != nil && !showNowPlaying {
Color.clear.frame(height: 80)
}
}
// 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
if isDynamicIsland {
// Dynamic Island layout at top
DynamicIslandView(
namespace: playerNamespace,
showNowPlaying: $showNowPlaying,
isDynamicIsland: $isDynamicIsland
)
.zIndex(3)
} else {
// Standard MiniPlayerBar at bottom with drag-to-morph
MiniPlayerBar(
showNowPlaying: $showNowPlaying,
namespace: playerNamespace
)
.padding(.bottom, showDocked ? 49 + effectiveDebugHeight : 49)
.animation(.easeInOut(duration: 0.25), value: showDocked)
.offset(y: dragOffset)
.gesture(
DragGesture(minimumDistance: 10)
.onChanged { value in
// Only allow upward drag
let translation = min(value.translation.height, 0)
dragOffset = translation
isDragging = true
}
.onEnded { value in
isDragging = false
let screenH = rootGeo.size.height
let velocity = value.predictedEndTranslation.height
let dragPct = abs(value.translation.height) / screenH
if dragPct > 0.35 || velocity < -800 {
// Morph to Dynamic Island
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
isDynamicIsland = true
dragOffset = 0
}
} else if value.translation.height < -20 {
// Small upward swipe open NowPlaying
dragOffset = 0
withAnimation(.easeInOut(duration: 0.35)) {
showNowPlaying = true
}
} else {
withAnimation(.spring(response: 0.3)) {
dragOffset = 0
}
}
}
)
.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
}
}
}
} // GeometryReader
}
// 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
var namespace: Namespace.ID
@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)
.matchedGeometryEffect(id: "albumArt", in: namespace)
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: - Dynamic Island Layout
struct DynamicIslandView: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@StateObject private var colorExtractor = AlbumColorExtractor.shared
var namespace: Namespace.ID
@Binding var showNowPlaying: Bool
@Binding var isDynamicIsland: Bool
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
VStack {
HStack(spacing: 0) {
// Leading: compact album art pill
Group {
if let song = audioPlayer.currentSong {
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
} else {
Color.gray.opacity(0.3)
}
}
.frame(width: 28, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.matchedGeometryEffect(id: "albumArt", in: namespace)
.padding(.leading, 8)
// Song info (compact)
VStack(alignment: .leading, spacing: 0) {
Text(audioPlayer.currentSong?.title ?? "")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
Text(audioPlayer.currentSong?.artist ?? "")
.font(.system(size: 9))
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
}
.padding(.leading, 6)
.frame(maxWidth: 100, alignment: .leading)
// Center spacer (hardware cutout zone)
Spacer()
// Trailing: compact visualizer
if VisualizerSettings.shared.enabled {
CompactVisualizerView(
isPlaying: audioPlayer.isPlaying,
accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink,
height: 28
)
.frame(width: 60, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
// Play/pause when no visualizer
Button(action: { audioPlayer.togglePlayPause() }) {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 14))
.foregroundColor(.white)
}
.frame(width: 36, height: 28)
}
Spacer().frame(width: 8)
}
.frame(height: 40)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(.black)
.shadow(color: .black.opacity(0.5), radius: 10, y: 2)
)
.padding(.horizontal, 40)
.padding(.top, 10)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.35)) {
showNowPlaying = true
isDynamicIsland = false
}
}
.gesture(
DragGesture(minimumDistance: 10)
.onEnded { value in
if value.translation.height > 40 {
// Drag down return to mini player
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
isDynamicIsland = false
}
}
}
)
Spacer()
}
.ignoresSafeArea(edges: .top)
}
}
// 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())
}
}