Companion API (4 new endpoints): - GET /lyrics/search — proxy to LRCLIB, returns matches with sync status - GET /lyrics/fetch — exact match by artist/title/duration, caches in SQLite - GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache - POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar - New lyrics table in companion database iOS data layer (LyricsModels.swift): - LyricLine/LyricWord structs with Codable conformance - Full LRC parser (line-level + enhanced word-level timestamps) - LRC serializer (toLRC) for saving edited timings - LyricsCache with memory + disk tiers keyed by artist-title iOS manager (LyricsManager.swift): - Source pipeline: embedded > .lrc sidecar > local cache > LRCLIB auto-fetch - Binary search line/word tracking (O(log n)) - Word-level progress for karaoke gradient fill - Hooked into AudioPlayer time observer (0.5s sync) and pushWidgetState (song change) iOS views: - LyricsOverlayView: karaoke word-by-word highlighting via FlowLayout + KaraokeWordView gradient fill sweep, ScrollViewReader auto-scroll, tap any line to seek - LyricsSearchSheet: LRCLIB search with debounced input, duration mismatch warnings, preview sheet, import to cache - LyricsEditorView: tap-to-sync mode, per-line ±0.1s adjust buttons, global offset sheet, save to device or embed in file via Companion API NowPlayingView integration: - Lyrics toggle button (quote.bubble) in bottom controls bar - Portrait layout swaps album art for lyrics overlay with animation - Blur panel: .ultraThinMaterial with gradient mask so visualizer fades through - Lyrics search/editor sheets wired with notification observer Mini player lyric ticker: - Both standard and compact mini player bars show current lyric line - Accent pink with ♪ prefix, falls back to artist name when no lyrics - Push transition animation on line changes Bug fixes included: - NavidromePlayerApp: removed unnecessary try on CompanionAPIService.shared - Info.plist: added LSSupportsOpeningDocumentsInPlace for document type support
982 lines
44 KiB
Swift
982 lines
44 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
|
|
@State private var wasInDynamicIsland = false // restore DI mode on NowPlaying dismiss
|
|
|
|
// 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,
|
|
wasInDynamicIsland: $wasInDynamicIsland
|
|
)
|
|
.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: showNowPlaying) { _, isShowing in
|
|
// If we were in DI mode when NowPlaying opened, restore it on dismiss
|
|
if !isShowing && wasInDynamicIsland {
|
|
wasInDynamicIsland = false
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
|
|
isDynamicIsland = true
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
Divider().frame(height: 14).background(Color.white.opacity(0.15))
|
|
dbgBodiesToggle()
|
|
}
|
|
.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 }
|
|
// ViewBody entries only shown when tracker is active
|
|
if entry.category == "ViewBody" { return DebugLogger.shared.trackViewBodies }
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Compact Bodies toggle for both PiP and docked panel.
|
|
/// Mirrors the ⚡ toggle in DebugConsoleView but accessible inline.
|
|
@ViewBuilder
|
|
private func dbgBodiesToggle() -> some View {
|
|
Button(action: { DebugLogger.shared.trackViewBodies.toggle() }) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: DebugLogger.shared.trackViewBodies ? "bolt.fill" : "bolt.slash")
|
|
.font(.system(size: 10))
|
|
Text("Bodies")
|
|
.font(.system(size: 10, weight: .medium))
|
|
}
|
|
.foregroundColor(DebugLogger.shared.trackViewBodies ? .orange : .gray.opacity(0.4))
|
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
.background(DebugLogger.shared.trackViewBodies ? Color.orange.opacity(0.2) : 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("\(filteredDebugEntries.count)/\(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 {
|
|
// ── Group toggles — same state as docked panel ───────────────
|
|
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)
|
|
Divider().frame(height: 14).background(Color.white.opacity(0.15))
|
|
dbgBodiesToggle()
|
|
}
|
|
.padding(.horizontal, 10)
|
|
}
|
|
.padding(.vertical, 5)
|
|
.background(Color(white: 0.10))
|
|
|
|
Divider().background(Color.white.opacity(0.1))
|
|
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 1) {
|
|
ForEach(filteredDebugEntries) { 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 {
|
|
let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("MiniPlayerBar") : false
|
|
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(
|
|
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,
|
|
songId: audioPlayer.currentSong?.id,
|
|
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)
|
|
if let lyricLine = LyricsManager.shared.currentLineText,
|
|
LyricsManager.shared.currentLyrics != nil {
|
|
Text("♪ \(lyricLine)")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333).opacity(0.8))
|
|
.lineLimit(1)
|
|
.transition(.push(from: .bottom))
|
|
.id("lyric_\(LyricsManager.shared.currentLineIndex)")
|
|
} else {
|
|
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
|
|
@Binding var wasInDynamicIsland: 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: 12)
|
|
|
|
HStack(spacing: 8) {
|
|
// ── Left pill: album art + song info ─────────────────────────
|
|
// Left-aligned, sits directly under the Dynamic Island
|
|
HStack(spacing: 8) {
|
|
Group {
|
|
if let song = audioPlayer.currentSong {
|
|
AsyncCoverArt(coverArtId: song.coverArt, size: 44)
|
|
} else {
|
|
Color.gray.opacity(0.3)
|
|
}
|
|
}
|
|
.frame(width: 30, height: 30)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
.matchedGeometryEffect(id: "albumArt", in: namespace)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(audioPlayer.currentSong?.title ?? "")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
if let lyricLine = LyricsManager.shared.currentLineText,
|
|
LyricsManager.shared.currentLyrics != nil {
|
|
Text("♪ \(lyricLine)")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333).opacity(0.8))
|
|
.lineLimit(1)
|
|
.id("lyric_compact_\(LyricsManager.shared.currentLineIndex)")
|
|
} else {
|
|
Text(audioPlayer.currentSong?.artist ?? "")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(.white.opacity(0.55))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.frame(maxWidth: 140, alignment: .leading)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.frame(height: 44)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
.fill(Color.black.opacity(0.85))
|
|
.shadow(color: themeColor.opacity(0.3), radius: 6, y: 2)
|
|
)
|
|
.contentShape(RoundedRectangle(cornerRadius: 22))
|
|
.onTapGesture {
|
|
wasInDynamicIsland = true
|
|
withAnimation(.easeInOut(duration: 0.35)) {
|
|
showNowPlaying = true
|
|
isDynamicIsland = false
|
|
}
|
|
}
|
|
|
|
// ── Visualizer fill ───────────────────────────────────────────
|
|
if VisualizerSettings.shared.enabled && VisualizerSettings.shared.miniPlayerEnabled {
|
|
CompactVisualizerView(
|
|
isPlaying: audioPlayer.isPlaying,
|
|
isSongLoaded: audioPlayer.currentSong != nil,
|
|
songId: audioPlayer.currentSong?.id,
|
|
accentColor: themeColor,
|
|
height: 44
|
|
)
|
|
.matchedGeometryEffect(id: "visWave", in: namespace)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 44)
|
|
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
.fill(Color.black.opacity(0.7))
|
|
)
|
|
} else {
|
|
Spacer()
|
|
}
|
|
|
|
// ── Play/pause circle button — matches debug PiP circle style ─
|
|
Button(action: { audioPlayer.togglePlayPause() }) {
|
|
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
.frame(width: 44, height: 44)
|
|
.background(
|
|
Circle()
|
|
.fill(Color.black.opacity(0.85))
|
|
.shadow(color: themeColor.opacity(0.3), radius: 6, y: 2)
|
|
)
|
|
}
|
|
}
|
|
.padding(.leading, 16)
|
|
.padding(.trailing, 16)
|
|
.gesture(
|
|
DragGesture(minimumDistance: 8)
|
|
.onEnded { value in
|
|
if value.translation.height > 25 {
|
|
wasInDynamicIsland = false
|
|
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 {
|
|
// No @ObservedObject on AudioPlayer — currentTime is no longer @Published.
|
|
// TimelineView drives re-evaluation at 10Hz independently so this view never
|
|
// triggers objectWillChange on AudioPlayer or propagates updates to siblings.
|
|
@ObservedObject var colorExtractor: AlbumColorExtractor
|
|
@Binding var isScrubbing: Bool
|
|
@Binding var scrubPosition: Double
|
|
let accentPink: Color
|
|
|
|
private var barColor: Color {
|
|
colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink
|
|
}
|
|
|
|
var body: some View {
|
|
let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("MiniProgressBar") : false
|
|
// GeometryReader and gesture live OUTSIDE TimelineView.
|
|
// Previously the gesture was inside the TimelineView closure — every 0.1s
|
|
// TimelineView re-evaluated its content, reconstructing the DragGesture
|
|
// and interrupting in-progress touches, causing missed/dropped scrubs.
|
|
// Only the fill Rectangle that reads currentTime sits inside TimelineView.
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
// Track background — static, no TimelineView needed
|
|
Rectangle()
|
|
.fill(Color.white.opacity(0.1))
|
|
.frame(height: isScrubbing ? 8 : 3)
|
|
|
|
// Fill — inside TimelineView so it updates at 10Hz without
|
|
// disturbing the gesture recognizer above
|
|
TimelineView(.periodic(from: .now, by: 0.1)) { _ in
|
|
let player = AudioPlayer.shared
|
|
let progress: Double = {
|
|
if isScrubbing { return scrubPosition }
|
|
guard player.duration > 0 else { return 0 }
|
|
return min(player.currentTime / player.duration, 1.0)
|
|
}()
|
|
Rectangle()
|
|
.fill(barColor)
|
|
.frame(width: geo.size.width * progress,
|
|
height: isScrubbing ? 8 : 3)
|
|
}
|
|
|
|
// Scrub thumb — only visible while dragging
|
|
if isScrubbing {
|
|
TimelineView(.periodic(from: .now, by: 0.1)) { _ in
|
|
let player = AudioPlayer.shared
|
|
let progress: Double = {
|
|
if isScrubbing { return scrubPosition }
|
|
guard player.duration > 0 else { return 0 }
|
|
return min(player.currentTime / player.duration, 1.0)
|
|
}()
|
|
Circle()
|
|
.fill(barColor)
|
|
.frame(width: 16, height: 16)
|
|
.offset(x: geo.size.width * progress - 8, y: -1)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
// Gesture is stable — never reconstructed by TimelineView ticks
|
|
.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.shared.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())
|
|
}
|
|
}
|