Update from NavidromePlayer.zip (2026-04-04 00:14)
This commit is contained in:
parent
36331d1f51
commit
4787e2a5d4
7 changed files with 133 additions and 47 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue