diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 1b00f9e..8245deb 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -807,6 +807,7 @@ class AudioPlayer: NSObject, ObservableObject { } else { queue.append(song) } + notifyQueueChanged() } func playLater(_ song: Song) { @@ -826,10 +827,10 @@ class AudioPlayer: NSObject, ObservableObject { queue.move(fromOffsets: source, toOffset: destination) - // Maintain queueIndex pointing to the currently playing song if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) { queueIndex = newIdx } + notifyQueueChanged() } /// Remove a song from the queue @@ -842,6 +843,17 @@ class AudioPlayer: NSObject, ObservableObject { } else { queueIndex = min(queueIndex, max(queue.count - 1, 0)) } + notifyQueueChanged() + } + + /// Re-prepare crossfade when queue is modified (Play Next, reorder, etc.) + private func notifyQueueChanged() { + #if os(iOS) + let crossfade = SmartCrossfadeManager.shared + guard crossfade.isEnabled, !queue.isEmpty else { return } + prepareNextForCrossfade() + AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex) + #endif } func playInstantMix(basedOn song: Song) { diff --git a/iOS/Views/Common/DebugConsoleView.swift b/iOS/Views/Common/DebugConsoleView.swift index 33aaf8e..fd08007 100644 --- a/iOS/Views/Common/DebugConsoleView.swift +++ b/iOS/Views/Common/DebugConsoleView.swift @@ -52,9 +52,15 @@ struct DebugConsoleView: View { @State private var filterText = "" @State private var selectedCategory: String? = nil @State private var autoScroll = true + @State private var showIPhone = true + @State private var showWatch = true + @State private var showCompanion = true private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + private let watchCategories: Set = ["Watch", "WC", "WatchSync"] + private let companionCategories: Set = ["Companion", "SmartDJ", "Crossfade", "Sync"] + private var categories: [String] { Array(Set(logger.entries.map { $0.category })).sorted() } @@ -65,7 +71,15 @@ struct DebugConsoleView: View { let matchesText = filterText.isEmpty || entry.message.localizedCaseInsensitiveContains(filterText) || entry.category.localizedCaseInsensitiveContains(filterText) - return matchesCategory && matchesText + + // Group filters + let isWatch = watchCategories.contains(entry.category) + let isCompanion = companionCategories.contains(entry.category) + let isIPhone = !isWatch && !isCompanion + + let matchesGroup = (isIPhone && showIPhone) || (isWatch && showWatch) || (isCompanion && showCompanion) + + return matchesCategory && matchesText && matchesGroup } } @@ -96,6 +110,16 @@ struct DebugConsoleView: View { .padding(.horizontal, 12) .padding(.top, 8) + // Group filter toggles + HStack(spacing: 8) { + groupToggle("iPhone", icon: "iphone", isOn: $showIPhone) + groupToggle("Watch", icon: "applewatch", isOn: $showWatch) + groupToggle("Companion", icon: "server.rack", isOn: $showCompanion) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + // Category pills if !categories.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -189,6 +213,22 @@ struct DebugConsoleView: View { } } + private func groupToggle(_ title: String, icon: String, isOn: Binding) -> 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 logRow(_ entry: DebugLogger.LogEntry) -> some View { let color: Color = { switch entry.category { diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index 494ef1e..8c37311 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -22,7 +22,7 @@ struct MainTabView: View { @State private var debugDragOffset: CGFloat = 0 // Debug PiP (floating) - @State private var debugPipMode = false + @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) @@ -126,7 +126,7 @@ struct MainTabView: View { let velocity = value.predictedEndTranslation.height let dragPct = abs(value.translation.height) / screenH - if dragPct > 0.35 || velocity < -800 { + if dragPct > 0.75 || velocity < -1200 { // Morph to Dynamic Island withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { isDynamicIsland = true @@ -655,14 +655,16 @@ struct DynamicIslandView: View { 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) { - // Generous drag zone above and around the pill - Color.clear - .frame(height: 20) + Spacer().frame(height: 54) // Below status bar + notch + // The pill — only this is tappable for NowPlaying HStack(spacing: 0) { - // Leading: compact album art pill Group { if let song = audioPlayer.currentSong { AsyncCoverArt(coverArtId: song.coverArt, size: 48) @@ -675,7 +677,6 @@ struct DynamicIslandView: View { .matchedGeometryEffect(id: "albumArt", in: namespace) .padding(.leading, 10) - // Song info (compact) VStack(alignment: .leading, spacing: 0) { Text(audioPlayer.currentSong?.title ?? "") .font(.system(size: 12, weight: .semibold)) @@ -683,17 +684,16 @@ struct DynamicIslandView: View { .lineLimit(1) Text(audioPlayer.currentSong?.artist ?? "") .font(.system(size: 10)) - .foregroundColor(.white.opacity(0.5)) + .foregroundColor(.white.opacity(0.6)) .lineLimit(1) } .padding(.leading, 8) .frame(maxWidth: .infinity, alignment: .leading) - // Trailing: compact visualizer or play button if VisualizerSettings.shared.enabled { CompactVisualizerView( isPlaying: audioPlayer.isPlaying, - accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink, + accentColor: themeColor, height: 32 ) .frame(width: 64, height: 32) @@ -712,37 +712,37 @@ struct DynamicIslandView: View { .frame(height: 48) .background( RoundedRectangle(cornerRadius: 26, style: .continuous) - .fill(.black) - .shadow(color: .black.opacity(0.6), radius: 12, y: 2) + .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) - - // Extra drag zone below the pill - Color.clear - .frame(height: 30) + .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() } - .statusBarHidden(true) - // Large hit area for the whole top region - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.35)) { - showNowPlaying = true - isDynamicIsland = false - } - } - .gesture( - DragGesture(minimumDistance: 8) - .onEnded { value in - if value.translation.height > 25 { - // Drag down — return to mini player - withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { - isDynamicIsland = false - } - } - } - ) + // Status bar stays visible — no .statusBarHidden } } diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index 6a05f6c..3a1f250 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -496,7 +496,11 @@ class CompanionPushClient: ObservableObject { DispatchQueue.main.async { self.lastEvent = msg.event - DebugLogger.shared.log("Push event: \(msg.event)", category: "Companion") + + // Suppress noisy pong events from log + if msg.event != "pong" { + DebugLogger.shared.log("Push event: \(msg.event)", category: "Companion") + } switch msg.event { case "metadata_updated": diff --git a/iOS/Views/Companion/SmartCrossfadeManager.swift b/iOS/Views/Companion/SmartCrossfadeManager.swift index e360f4b..df53431 100644 --- a/iOS/Views/Companion/SmartCrossfadeManager.swift +++ b/iOS/Views/Companion/SmartCrossfadeManager.swift @@ -185,7 +185,21 @@ class SmartCrossfadeManager: ObservableObject { func seek(to time: TimeInterval) { let cmTime = CMTime(seconds: time, preferredTimescale: 1000) - activePlayer.seek(to: cmTime) + + // Remove existing boundary observer before seeking + // to prevent it firing during the seek operation + removeBoundaryObserver() + + activePlayer.seek(to: cmTime) { [weak self] finished in + guard finished, let self else { return } + // Reinstall boundary observer AFTER seek completes + // Only if we haven't seeked past the trigger point + DispatchQueue.main.async { + if !self.isCrossfading { + self.setupBoundaryObserver() + } + } + } } func stop() { @@ -272,6 +286,13 @@ class SmartCrossfadeManager: ObservableObject { // Trigger = content end minus crossfade duration (min 1s from start) let triggerTime = max(1.0, contentEnd - crossfadeDuration) + // Don't install if we've already seeked past the trigger + let currentPos = activePlayer.currentTime().seconds + if currentPos.isFinite && currentPos >= triggerTime { + log("⏭ Skipping trigger — already past \(String(format: "%.1f", triggerTime))s") + return + } + log("🎯 Trigger at \(String(format: "%.1f", triggerTime))s " + "(content \(String(format: "%.1f", contentEnd))s, " + "dur \(String(format: "%.1f", duration))s)") diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index d4f6c3a..37e5c02 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -409,7 +409,7 @@ struct NowPlayingView: View { .overlay(Color.black.opacity(0.4)) } .ignoresSafeArea() - .id(audioPlayer.currentSong?.id ?? "none") + .id(audioPlayer.currentSong?.albumId ?? audioPlayer.currentSong?.id ?? "none") .transition(.opacity) } @@ -425,7 +425,7 @@ struct NowPlayingView: View { ) .ignoresSafeArea() } - .animation(.easeInOut(duration: 0.6), value: audioPlayer.currentSong?.id) + .animation(.easeInOut(duration: 0.6), value: audioPlayer.currentSong?.albumId) } // MARK: - Top Bar (portrait only) @@ -1325,7 +1325,7 @@ struct RadioRecordingsView: View { .foregroundColor(.white) .lineLimit(1) HStack(spacing: 8) { - Text(rec.date, style: .date) + Text(rec.date, format: .dateTime.month(.wide).day().year()) .font(.system(size: 11)) .foregroundColor(.gray) Text(rec.size) diff --git a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift index 1d7b7de..3cb4371 100644 --- a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift +++ b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift @@ -152,11 +152,19 @@ struct MitsuhaVisualizerView: View { GeometryReader { geo in TimelineView(.animation(minimumInterval: 1.0 / settings.effectiveFPS)) { timeline in // PULL: Grab latest levels right before rendering — no @Published thrashing - let rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels() + let rawLevels: [Float] + if isPlaying { + rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels() + } else { + // Feed zeros so levels decay smoothly via viscosity + rawLevels = Array(repeating: Float(0), count: max(settings.numberOfPoints, 1)) + } let _ = updateDisplayLevelsIfNeeded(newRawLevels: rawLevels) Canvas(opaque: false) { context, size in - let pts = isPlaying ? displayLevels : Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints) + let pts = displayLevels.isEmpty + ? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints) + : displayLevels guard pts.count >= 2 else { return } switch settings.style { case .wave: drawWave(ctx: context, size: size, levels: pts, date: timeline.date) @@ -172,8 +180,9 @@ struct MitsuhaVisualizerView: View { } ) } - .opacity(isPlaying ? 1 : 0) - .animation(.easeInOut(duration: 0.6), value: isPlaying) + // Smooth fade instead of hard cut — keeps TimelineView alive + .opacity(isPlaying ? 1 : Double(settings.idleAmplitude) > 0 ? 0.4 : 0) + .animation(.easeInOut(duration: 0.8), value: isPlaying) .onAppear { displayLevels = Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints) }