- AudioTapProcessor: shared MTAudioProcessingTap with lock-free PCM ring buffer - Pre-allocated vDSP FFT (1024-sample, Hann window, log-frequency 30-band output) - Zero per-frame heap allocation in FFT path - Shared tap serves both FFT visualizer and Shazam simultaneously Fixes (blockers for tap to work): - radioGoLive/radioSeekBack now update self.playerItem (was orphaned) - Tap reinstalled on every AVPlayerItem swap (seek, live, station change) - Tap removed on background, reinstalled on foreground - Tap removed on radio→music transition Shazam rework: - Uses shared AudioTapProcessor instead of creating its own tap - Fixes tap conflict where Shazam overwrote FFT audioMix - 500ms wait for tapPrepare callback (sourceFormat timing race) - Fixed pre-existing bug: stopAll() audio session never restored after mic fallback Debug capture: - Capture Audio Tap button in Visualizer Settings - Records 5s of raw tap PCM as playable WAV file - Uses actual stream sample rate (not hardcoded 44100) - Share sheet via Notification pattern (survives view dismiss) - Spinner auto-resets on appear if capture interrupted by background Also includes from main branch: - Edit History UI, batch undo, companion API 7-bug fix - Recently Played tab, Discover section, Play Queue sync, Share links"
218 lines
9.2 KiB
Swift
218 lines
9.2 KiB
Swift
import SwiftUI
|
|
import BackgroundTasks
|
|
|
|
// MARK: - App Delegate (Background Sessions + BGTask Registration)
|
|
|
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
|
|
|
static let smartDJTaskId = "com.navidromeplayer.smartdj.refresh"
|
|
static let syncTaskId = "com.navidromeplayer.library.sync"
|
|
|
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
|
// Register background tasks
|
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.smartDJTaskId, using: nil) { task in
|
|
self.handleSmartDJRefresh(task: task as! BGAppRefreshTask)
|
|
}
|
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.syncTaskId, using: nil) { task in
|
|
self.handleLibrarySync(task: task as! BGProcessingTask)
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Return a custom scene configuration so NavidromeSceneDelegate can provide
|
|
/// NavidromeHostingController — giving us UIKit-level preferredStatusBarStyle
|
|
/// control without touching SwiftUI's colorScheme environment.
|
|
func application(
|
|
_ application: UIApplication,
|
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
|
options: UIScene.ConnectionOptions
|
|
) -> UISceneConfiguration {
|
|
let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
|
|
config.delegateClass = NavidromeSceneDelegate.self
|
|
return config
|
|
}
|
|
|
|
func application(
|
|
_ application: UIApplication,
|
|
handleEventsForBackgroundURLSession identifier: String,
|
|
completionHandler: @escaping () -> Void
|
|
) {
|
|
if identifier == "com.navidromeplayer.batchupload" {
|
|
ZipImportManager.shared.setBackgroundCompletionHandler(completionHandler)
|
|
DebugLogger.shared.log("Background session woke app: \(identifier)", category: "Upload")
|
|
}
|
|
}
|
|
|
|
// MARK: - Smart DJ Background Refresh
|
|
|
|
private func handleSmartDJRefresh(task: BGAppRefreshTask) {
|
|
Self.scheduleSmartDJRefresh() // re-schedule next run immediately
|
|
|
|
let refreshTask = Task {
|
|
// Pre-fetch profiles using the shared singleton — no per-song URLSession leak
|
|
if CompanionSettings.shared.isEnabled,
|
|
CompanionSettings.shared.smartDJEnabled {
|
|
let queue = AudioPlayer.shared.queue
|
|
let idx = AudioPlayer.shared.queueIndex
|
|
let upcoming = Array(queue.dropFirst(idx + 1).prefix(5))
|
|
let api = CompanionAPIService.shared
|
|
for song in upcoming {
|
|
guard let path = song.path else { continue }
|
|
_ = try? await api.fetchProfile(relativePath: path)
|
|
}
|
|
}
|
|
|
|
// Await the actual sync — BGTask stays alive until work completes
|
|
await SyncEngine.shared.syncAndWait()
|
|
OptimisticActionQueue.shared.flush()
|
|
|
|
task.setTaskCompleted(success: true)
|
|
DebugLogger.shared.log("BGTask SmartDJ refresh completed", category: "Sync", level: .info)
|
|
}
|
|
|
|
task.expirationHandler = {
|
|
refreshTask.cancel()
|
|
task.setTaskCompleted(success: false)
|
|
DebugLogger.shared.log("BGTask SmartDJ refresh expired", category: "Sync", level: .warning)
|
|
}
|
|
}
|
|
|
|
private func handleLibrarySync(task: BGProcessingTask) {
|
|
let syncTask = Task {
|
|
// Await real completion — previously fired setTaskCompleted immediately
|
|
// before any sync work started, causing iOS to kill the process (AUDIT-044)
|
|
await SyncEngine.shared.syncAndWait()
|
|
task.setTaskCompleted(success: true)
|
|
DebugLogger.shared.log("BGTask library sync completed", category: "Sync", level: .info)
|
|
}
|
|
task.expirationHandler = {
|
|
syncTask.cancel()
|
|
task.setTaskCompleted(success: false)
|
|
DebugLogger.shared.log("BGTask library sync expired", category: "Sync", level: .warning)
|
|
}
|
|
}
|
|
|
|
static func scheduleSmartDJRefresh() {
|
|
let request = BGAppRefreshTaskRequest(identifier: smartDJTaskId)
|
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
|
|
try? BGTaskScheduler.shared.submit(request)
|
|
}
|
|
|
|
static func scheduleLibrarySync() {
|
|
let request = BGProcessingTaskRequest(identifier: syncTaskId)
|
|
request.requiresNetworkConnectivity = true
|
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // 1 hour
|
|
try? BGTaskScheduler.shared.submit(request)
|
|
}
|
|
}
|
|
|
|
@main
|
|
struct NavidromePlayerApp: App {
|
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|
|
|
@StateObject private var serverManager = ServerManager.shared
|
|
@StateObject private var audioPlayer = AudioPlayer.shared
|
|
@StateObject private var offlineManager = OfflineManager.shared
|
|
|
|
// Initialize WatchConnectivity so sync works immediately
|
|
private let watchConnectivity = WatchConnectivityManager.shared
|
|
|
|
init() {
|
|
// Dismiss keyboard when scrolling any scroll view
|
|
UIScrollView.appearance().keyboardDismissMode = .interactive
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
RootView()
|
|
.environmentObject(serverManager)
|
|
.environmentObject(audioPlayer)
|
|
.environmentObject(offlineManager)
|
|
.tint(Color(red: 1.0, green: 0.176, blue: 0.333))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RootView: View {
|
|
@EnvironmentObject var serverManager: ServerManager
|
|
|
|
var body: some View {
|
|
Group {
|
|
if serverManager.servers.isEmpty {
|
|
// No servers configured — show login
|
|
LoginView()
|
|
} else {
|
|
// Servers exist — show main UI immediately (connects in background)
|
|
MainTabView()
|
|
}
|
|
}
|
|
.dismissKeyboardOnTap()
|
|
.task {
|
|
ImageCache.shared.trimDiskCache()
|
|
AudioPreFetcher.shared.cleanOldPrefetches(keeping: AudioPlayer.shared.queue)
|
|
WidgetBridge.shared.start()
|
|
|
|
if serverManager.activeServer != nil {
|
|
await serverManager.connectToActive()
|
|
|
|
// Background sync — fills LibraryCache so UI never waits for network
|
|
SyncEngine.shared.syncIfNeeded()
|
|
|
|
// Flush any pending optimistic actions (star/unstar that failed offline)
|
|
OptimisticActionQueue.shared.flush()
|
|
|
|
// Bulk-fetch all Smart DJ profiles in one request (~30KB gzipped).
|
|
// Populates SmartDJCache so crossfade/volume data is instant for every song.
|
|
if CompanionSettings.shared.smartDJEnabled {
|
|
SmartDJCache.shared.loadBulkCache() // disk → memory (instant)
|
|
Task.detached(priority: .utility) {
|
|
do {
|
|
let api = CompanionAPIService.shared
|
|
let profiles = try await api.fetchAllProfiles()
|
|
SmartDJCache.shared.bulkImport(profiles)
|
|
} catch {
|
|
DebugLogger.shared.log(
|
|
"DJ profile bulk fetch failed: \(error.localizedDescription)",
|
|
category: "SmartDJ"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore queue from last session if the app was killed by iOS.
|
|
// Only restore if no song is already playing (e.g. from a widget tap).
|
|
// Restoration starts paused at the saved position — user taps Play.
|
|
if AudioPlayer.shared.currentSong == nil,
|
|
let saved = PlaybackStateStore.shared.load() {
|
|
let player = AudioPlayer.shared
|
|
// Load queue without starting playback
|
|
player.queue = saved.queue
|
|
player.queueIndex = saved.index
|
|
player.currentSong = saved.currentSong
|
|
player.currentTime = saved.currentTime
|
|
// Duration will populate when the player item loads
|
|
DebugLogger.shared.log(
|
|
"Restored queue: \(saved.queue.count) songs, index \(saved.index), t=\(Int(saved.currentTime))s",
|
|
category: "Audio", level: .info
|
|
)
|
|
}
|
|
|
|
// Play Queue Sync — start observing for saves, check server for cross-device resume
|
|
let queueSync = PlayQueueSyncManager.shared
|
|
queueSync.start()
|
|
if AudioPlayer.shared.currentSong == nil {
|
|
await queueSync.checkServerQueue()
|
|
}
|
|
}
|
|
|
|
// Connect Companion push client if enabled
|
|
if CompanionSettings.shared.isEnabled {
|
|
CompanionPushClient.shared.connect()
|
|
}
|
|
|
|
// Schedule background tasks
|
|
AppDelegate.scheduleSmartDJRefresh()
|
|
AppDelegate.scheduleLibrarySync()
|
|
}
|
|
}
|
|
}
|