NavidromeApp/iOS/App/NavidromePlayerApp.swift

219 lines
9.2 KiB
Swift
Raw Normal View History

import SwiftUI
2026-04-03 19:20:55 -07:00
import BackgroundTasks
2026-04-03 19:20:55 -07:00
// MARK: - App Delegate (Background Sessions + BGTask Registration)
class AppDelegate: NSObject, UIApplicationDelegate {
2026-04-03 19:20:55 -07:00
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")
}
}
2026-04-03 19:20:55 -07:00
// MARK: - Smart DJ Background Refresh
private func handleSmartDJRefresh(task: BGAppRefreshTask) {
2026-04-11 15:09:06 -07:00
Self.scheduleSmartDJRefresh() // re-schedule next run immediately
2026-04-03 19:20:55 -07:00
let refreshTask = Task {
2026-04-11 15:37:14 -07:00
// 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)
2026-04-03 19:20:55 -07:00
}
2026-04-11 15:37:14 -07:00
}
2026-04-11 15:09:06 -07:00
2026-04-11 15:37:14 -07:00
// Await the actual sync BGTask stays alive until work completes
await SyncEngine.shared.syncAndWait()
OptimisticActionQueue.shared.flush()
2026-04-11 15:09:06 -07:00
2026-04-11 15:37:14 -07:00
task.setTaskCompleted(success: true)
DebugLogger.shared.log("BGTask SmartDJ refresh completed", category: "Sync", level: .info)
2026-04-03 19:20:55 -07:00
}
2026-04-11 15:09:06 -07:00
task.expirationHandler = {
refreshTask.cancel()
task.setTaskCompleted(success: false)
DebugLogger.shared.log("BGTask SmartDJ refresh expired", category: "Sync", level: .warning)
}
2026-04-03 19:20:55 -07:00
}
private func handleLibrarySync(task: BGProcessingTask) {
let syncTask = Task {
2026-04-11 15:09:06 -07:00
// Await real completion previously fired setTaskCompleted immediately
// before any sync work started, causing iOS to kill the process (AUDIT-044)
await SyncEngine.shared.syncAndWait()
2026-04-03 19:20:55 -07:00
task.setTaskCompleted(success: true)
2026-04-11 15:09:06 -07:00
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)
2026-04-03 19:20:55 -07:00
}
}
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
2026-04-10 07:09:57 -07:00
2026-04-10 07:15:49 -07:00
@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
2026-04-10 07:09:57 -07:00
init() {
2026-04-10 07:15:49 -07:00
// Dismiss keyboard when scrolling any scroll view
UIScrollView.appearance().keyboardDismissMode = .interactive
}
2026-04-10 07:09:57 -07:00
var body: some Scene {
WindowGroup {
2026-04-10 07:15:49 -07:00
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)
2026-04-12 12:57:42 -07:00
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()
Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache PERFORMANCE AUDIT - Removed 16 dead SubsonicClient methods (~117 lines) - Added NSCache memory tier to LibraryCache, AlbumCoverStore, ArtistCoverStore, RadioCoverStore - Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache - Split PlaybackStateStore into save() (full queue) and savePosition() (time only) - Reused single SubsonicClient in OfflineManager instead of per-download allocation - Added periodic ImageCache disk trim every 50 writes - Changed AudioPreFetcher to fuzzy offline match (isSongAvailableOffline) - Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive, CachedImageLoader.task - Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache (no shared instances) WIDGET EXTENSION (new target: NavidromeWidget) - v2 glassmorphism design: blurred album art background + frosted glass panel - Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via SeekToIntent) - Color-adaptive theming: CIAreaAverage dominant color extraction with HSB contrast adjustment - Transport controls: previous/play-pause/next with interactive AppIntents - Up Next footer with crossfade countdown from Smart DJ profiles - Large widget: 3-item queue list with numbered rows - Small/Medium/Large sizes matching design mockups - App Group communication via WidgetSharedState (UserDefaults) - Darwin notification observer for widget→app commands - Foreground command pickup for suspended app recovery - Idempotency guards on all widget commands CROSSFADE & PLAYBACK FIXES - Fixed dual audio on single-song queue: guard nextSong.id == currentSong?.id in prepareNextForCrossfade - Fixed crossfade play path never calling pushWidgetState (returned before reaching it) - Fixed crossfade needsNextTrack callback missing queue persistence + widget push - Fixed toggleShuffle queue not persisted after PlaybackStateStore split - Added nowPlayingSyncTimer restart on foreground (Lock Screen seek bar drift) - Added AVPlayer currentTime/duration sync in resumeVisTimers before vis timer restart (fixes waveform distortion after background — confirmed by Apple Forums + SoundCloud engineering) COVER ART PIPELINE - Fixed pushWidgetState cover art size mismatch (300→600 to match fetchAndSetArtwork) - Added custom cover art key differentiation ("custom_" prefix forces re-blur) - Changed server art lookup from memoryOnlyImage to cachedImage (memory+disk fallback) - Added POST /library/cover-art-by-path endpoint (was missing — iOS fallback hit 404) - Added navidrome_id fallback on existing cover art endpoint - Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen writes cover art directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves updated art - All three upload paths (by-id, by-path, upload-tracks) now embed + trigger_scan COMPANION API FIXES - Fixed _create_task recursion (was calling itself instead of asyncio.create_task) - Fixed navidrome_db NameError on /library/conflicts endpoint - Reduced WebSocket connect/disconnect logging (only first-client and all-disconnected) SMART DJ PROFILE PREFETCH - New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip automatic) - SmartDJCache.loadBulkCache() reads single file on launch (instant) - SmartDJCache.bulkImport() writes all profiles in one atomic file - CompanionAPIService.fetchAllProfiles() fetches entire profile set in one request - Wired into NavidromePlayerApp.task after server connect - SmartCrossfadeManager unchanged — already reads from SmartDJCache first WEBSOCKET NOISE REDUCTION - iOS: silent reconnect retries, only log milestones (#1, #5, every 20th) - iOS: log "reconnected after N attempts" on success, silent initial connect - Python: only log first client connect and all-clients-disconnected Files: 13 modified, 8 new (including companion-api/main.py)
2026-04-12 19:24:22 -07:00
// 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 {
Live lyrics system — full implementation 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
2026-04-13 09:01:30 -07:00
let api = CompanionAPIService.shared
Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache PERFORMANCE AUDIT - Removed 16 dead SubsonicClient methods (~117 lines) - Added NSCache memory tier to LibraryCache, AlbumCoverStore, ArtistCoverStore, RadioCoverStore - Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache - Split PlaybackStateStore into save() (full queue) and savePosition() (time only) - Reused single SubsonicClient in OfflineManager instead of per-download allocation - Added periodic ImageCache disk trim every 50 writes - Changed AudioPreFetcher to fuzzy offline match (isSongAvailableOffline) - Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive, CachedImageLoader.task - Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache (no shared instances) WIDGET EXTENSION (new target: NavidromeWidget) - v2 glassmorphism design: blurred album art background + frosted glass panel - Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via SeekToIntent) - Color-adaptive theming: CIAreaAverage dominant color extraction with HSB contrast adjustment - Transport controls: previous/play-pause/next with interactive AppIntents - Up Next footer with crossfade countdown from Smart DJ profiles - Large widget: 3-item queue list with numbered rows - Small/Medium/Large sizes matching design mockups - App Group communication via WidgetSharedState (UserDefaults) - Darwin notification observer for widget→app commands - Foreground command pickup for suspended app recovery - Idempotency guards on all widget commands CROSSFADE & PLAYBACK FIXES - Fixed dual audio on single-song queue: guard nextSong.id == currentSong?.id in prepareNextForCrossfade - Fixed crossfade play path never calling pushWidgetState (returned before reaching it) - Fixed crossfade needsNextTrack callback missing queue persistence + widget push - Fixed toggleShuffle queue not persisted after PlaybackStateStore split - Added nowPlayingSyncTimer restart on foreground (Lock Screen seek bar drift) - Added AVPlayer currentTime/duration sync in resumeVisTimers before vis timer restart (fixes waveform distortion after background — confirmed by Apple Forums + SoundCloud engineering) COVER ART PIPELINE - Fixed pushWidgetState cover art size mismatch (300→600 to match fetchAndSetArtwork) - Added custom cover art key differentiation ("custom_" prefix forces re-blur) - Changed server art lookup from memoryOnlyImage to cachedImage (memory+disk fallback) - Added POST /library/cover-art-by-path endpoint (was missing — iOS fallback hit 404) - Added navidrome_id fallback on existing cover art endpoint - Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen writes cover art directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves updated art - All three upload paths (by-id, by-path, upload-tracks) now embed + trigger_scan COMPANION API FIXES - Fixed _create_task recursion (was calling itself instead of asyncio.create_task) - Fixed navidrome_db NameError on /library/conflicts endpoint - Reduced WebSocket connect/disconnect logging (only first-client and all-disconnected) SMART DJ PROFILE PREFETCH - New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip automatic) - SmartDJCache.loadBulkCache() reads single file on launch (instant) - SmartDJCache.bulkImport() writes all profiles in one atomic file - CompanionAPIService.fetchAllProfiles() fetches entire profile set in one request - Wired into NavidromePlayerApp.task after server connect - SmartCrossfadeManager unchanged — already reads from SmartDJCache first WEBSOCKET NOISE REDUCTION - iOS: silent reconnect retries, only log milestones (#1, #5, every 20th) - iOS: log "reconnected after N attempts" on success, silent initial connect - Python: only log first client connect and all-clients-disconnected Files: 13 modified, 8 new (including companion-api/main.py)
2026-04-12 19:24:22 -07:00
let profiles = try await api.fetchAllProfiles()
SmartDJCache.shared.bulkImport(profiles)
} catch {
DebugLogger.shared.log(
"DJ profile bulk fetch failed: \(error.localizedDescription)",
category: "SmartDJ"
)
}
}
}
2026-04-11 15:09:06 -07:00
// 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
)
}
2026-04-17 09:58:05 -07:00
// 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()
}
2026-04-03 19:20:55 -07:00
// Schedule background tasks
AppDelegate.scheduleSmartDJRefresh()
AppDelegate.scheduleLibrarySync()
}
}
}