NavidromeApp/iOS/Views/Common/MainTabView.swift
2026-04-11 15:37:14 -07:00

880 lines
38 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
@State private var showFullConsole = false
// Group toggles same @AppStorage keys as DebugConsoleView so state is shared
@AppStorage("dbg_show_iphone") private var dbgShowIPhone = true
@AppStorage("dbg_show_watch") private var dbgShowWatch = true
@AppStorage("dbg_show_companion") private var dbgShowCompanion = true
@AppStorage("dbg_show_audio") private var dbgShowAudio = true
// Debug PiP (floating)
@State private var debugPipMode = true
@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.75 || velocity < -1200 {
// 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)
}
}
.onChange(of: debugLogger.isPiP) { _, newVal in
// DebugConsoleView's PiP button sets logger.isPiP; sync to local state
if newVal { debugPipMode = true }
}
.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("\(filteredDebugEntries.count)/\(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))
}
// Open full console with all controls
Button(action: { showFullConsole = true }) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 13)).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, 4)
// Group toggles
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
dbgGroupToggle("iPhone", icon: "iphone", isOn: $dbgShowIPhone)
dbgGroupToggle("Watch", icon: "applewatch", isOn: $dbgShowWatch)
dbgGroupToggle("Companion", icon: "server.rack", isOn: $dbgShowCompanion)
dbgGroupToggle("Audio", icon: "waveform", isOn: $dbgShowAudio)
}
.padding(.horizontal, 14)
}
.padding(.bottom, 6)
}
.background(Color(white: 0.1))
Divider().background(Color.white.opacity(0.15))
// Log entries filtered by group toggles
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 1) {
ForEach(filteredDebugEntries) { entry in
debugLogRow(entry).id(entry.id)
}
}
.padding(.horizontal, 10).padding(.vertical, 4)
}
.onChange(of: DebugLogger.shared.entries.count) { _, _ in
if let last = filteredDebugEntries.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)
.sheet(isPresented: $showFullConsole) { DebugConsoleView() }
.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)
}
}
)
}
// Filtered entries for the docked panel using the same group toggle AppStorage keys
private var filteredDebugEntries: [DebugLogger.LogEntry] {
let watchCats: Set<String> = ["Watch", "WC", "WatchSync"]
let companionCats: Set<String> = ["Companion", "SmartDJ", "Crossfade", "Sync"]
let audioCats: Set<String> = ["Audio", "FFT", "Radio", "Prefetch"]
return DebugLogger.shared.entries.filter { entry in
if entry.isMarker { return true }
let isWatch = watchCats.contains(entry.category)
let isCompanion = companionCats.contains(entry.category)
let isAudio = audioCats.contains(entry.category)
let isIPhone = !isWatch && !isCompanion && !isAudio
return (isIPhone && dbgShowIPhone) || (isWatch && dbgShowWatch) ||
(isCompanion && dbgShowCompanion) || (isAudio && dbgShowAudio)
}
}
private func dbgGroupToggle(_ title: String, icon: String, isOn: Binding<Bool>) -> some View {
Button(action: { isOn.wrappedValue.toggle() }) {
HStack(spacing: 4) {
Image(systemName: icon).font(.system(size: 10))
Text(title).font(.system(size: 10, weight: .medium))
}
.foregroundColor(isOn.wrappedValue ? .white : .gray.opacity(0.4))
.padding(.horizontal, 8).padding(.vertical, 4)
.background(isOn.wrappedValue ? accentPink.opacity(0.4) : Color.white.opacity(0.05))
.cornerRadius(10)
}
}
private func debugLogRow(_ entry: DebugLogger.LogEntry) -> some View {
if entry.isMarker {
return AnyView(
Text(entry.message)
.font(.system(size: 9, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.2))
.frame(maxWidth: .infinity).padding(.vertical, 4)
)
}
let color: Color = {
switch entry.category {
case "Watch", "WC", "WatchSync": return .cyan
case "Audio", "FFT": return .green
case "Network", "Radio": return .yellow
case "Error": return .red
case "Companion", "SmartDJ": return .purple
default: return .gray
}
}()
return AnyView(
HStack(alignment: .top, spacing: 5) {
Circle()
.fill(entry.level.color)
.frame(width: 4, height: 4)
.padding(.top, 3)
.opacity(entry.level == .debug ? 0.4 : 1.0)
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(entry.level == .error ? .red.opacity(0.9) :
entry.level == .warning ? .yellow.opacity(0.9) : .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
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
VStack(spacing: 0) {
// Progress bar extracted into its own view only it re-evaluates at 10Hz
// when audioPlayer.currentTime changes. The rest of MiniPlayerBar body
// only re-evaluates on song change, play/pause, or color change.
MiniProgressBar(
audioPlayer: audioPlayer,
colorExtractor: colorExtractor,
isScrubbing: $isScrubbing,
scrubPosition: $scrubPosition,
accentPink: accentPink
)
ZStack(alignment: .center) {
// Visualizer behind controls paused when full NowPlaying is open
if VisualizerSettings.shared.enabled && VisualizerSettings.shared.miniPlayerEnabled && !showNowPlaying {
CompactVisualizerView(
isPlaying: audioPlayer.isPlaying,
isSongLoaded: audioPlayer.currentSong != nil,
accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink,
height: VisualizerSettings.shared.miniPlayerHeight
)
.matchedGeometryEffect(id: "visWave", in: namespace)
.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)
}
}
// 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)
private var themeColor: Color {
colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink
}
var body: some View {
VStack(spacing: 0) {
Spacer().frame(height: 54) // Below status bar + notch
// The pill only this is tappable for NowPlaying
HStack(spacing: 0) {
Group {
if let song = audioPlayer.currentSong {
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
} else {
Color.gray.opacity(0.3)
}
}
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.matchedGeometryEffect(id: "albumArt", in: namespace)
.padding(.leading, 10)
VStack(alignment: .leading, spacing: 0) {
Text(audioPlayer.currentSong?.title ?? "")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
Text(audioPlayer.currentSong?.artist ?? "")
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.6))
.lineLimit(1)
}
.padding(.leading, 8)
.frame(maxWidth: .infinity, alignment: .leading)
if VisualizerSettings.shared.enabled {
CompactVisualizerView(
isPlaying: audioPlayer.isPlaying,
isSongLoaded: audioPlayer.currentSong != nil,
accentColor: themeColor,
height: 32
)
.matchedGeometryEffect(id: "visWave", in: namespace)
.frame(width: 64, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
Button(action: { audioPlayer.togglePlayPause() }) {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 16))
.foregroundColor(.white)
}
.frame(width: 40, height: 32)
}
Spacer().frame(width: 10)
}
.frame(height: 48)
.background(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(
LinearGradient(
colors: [Color.black, themeColor.opacity(0.25)],
startPoint: .leading,
endPoint: .trailing
)
)
.shadow(color: themeColor.opacity(0.3), radius: 12, y: 2)
)
.padding(.horizontal, 32)
.contentShape(RoundedRectangle(cornerRadius: 26))
.onTapGesture {
withAnimation(.easeInOut(duration: 0.35)) {
showNowPlaying = true
isDynamicIsland = false
}
}
.gesture(
DragGesture(minimumDistance: 8)
.onEnded { value in
if value.translation.height > 25 {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
isDynamicIsland = false
}
}
}
)
Spacer()
}
// Status bar stays visible no .statusBarHidden
}
}
// MARK: - Mini Player Progress Bar (isolated re-evaluation)
//
// Extracted from MiniPlayerBar so only this tiny view re-evaluates when
// audioPlayer.currentTime changes (10x/second from the periodic time observer).
// Before this, the ENTIRE MiniPlayerBar body re-evaluated at 10Hz including
// the GeometryReader, CompactVisualizerView, all ZStack children, and album art
// causing sustained ~128% CPU even with the visualizer off.
//
// The key trick: this view declares its OWN @ObservedObject on audioPlayer so
// SwiftUI's dependency tracking scopes the 10Hz invalidation to THIS view only.
// MiniPlayerBar.body is now only re-evaluated on song change, play/pause, or
// color change not on every currentTime tick.
private struct MiniProgressBar: View {
@ObservedObject var audioPlayer: AudioPlayer
@ObservedObject var colorExtractor: AlbumColorExtractor
@Binding var isScrubbing: Bool
@Binding var scrubPosition: Double
let accentPink: Color
private var displayProgress: Double {
if isScrubbing { return scrubPosition }
guard audioPlayer.duration > 0 else { return 0 }
return min(audioPlayer.currentTime / audioPlayer.duration, 1.0)
}
private var barColor: Color {
colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink
}
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.1))
.frame(height: isScrubbing ? 8 : 3)
Rectangle()
.fill(barColor)
.frame(
width: geo.size.width * displayProgress,
height: isScrubbing ? 8 : 3
)
if isScrubbing {
Circle()
.fill(barColor)
.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)
scrubPosition = pct
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isScrubbing = false
}
}
)
}
.frame(height: isScrubbing ? 20 : 14)
.animation(.easeInOut(duration: 0.15), value: isScrubbing)
}
}
// 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())
}
}