Update from NavidromePlayer.zip (2026-04-04 00:14)

This commit is contained in:
Dallas Groot 2026-04-04 00:14:41 -07:00
parent 36331d1f51
commit 4787e2a5d4
7 changed files with 133 additions and 47 deletions

View file

@ -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) {

View file

@ -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<String> = ["Watch", "WC", "WatchSync"]
private let companionCategories: Set<String> = ["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<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 logRow(_ entry: DebugLogger.LogEntry) -> some View {
let color: Color = {
switch entry.category {

View file

@ -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
}
}

View file

@ -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":

View file

@ -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)")

View file

@ -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)

View file

@ -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)
}