memory improvements

This commit is contained in:
Dallas Groot 2026-04-11 15:37:14 -07:00
parent 0730fa11f8
commit 85c85c2090
5 changed files with 601 additions and 368 deletions

File diff suppressed because it is too large Load diff

View file

@ -50,35 +50,25 @@ class AppDelegate: NSObject, UIApplicationDelegate {
Self.scheduleSmartDJRefresh() // re-schedule next run immediately
let refreshTask = Task {
// setTaskCompleted is always called in success path, catch, or expiry.
// Previously this called syncIfNeeded() (fire-and-forget) then returned,
// reporting success before any work was done (AUDIT-044/052).
do {
// 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 // shared instance, no per-song alloc
for song in upcoming {
guard let path = song.path else { continue }
_ = try? await api.fetchProfile(relativePath: path)
}
// 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)
} catch {
// Explicit catch ensures setTaskCompleted is always called
// previously a throw would leave the task hanging until iOS killed it
task.setTaskCompleted(success: false)
DebugLogger.shared.log("BGTask SmartDJ refresh failed: \(error.localizedDescription)", category: "Sync", level: .warning)
}
// 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 = {

View file

@ -4,7 +4,7 @@ import UIKit
// MARK: - Image Cache (Memory + Disk)
/// Two-tier image cache: NSCache for fast memory access, disk for persistence across launches.
class ImageCache {
class ImageCache: @unchecked Sendable {
static let shared = ImageCache()
private let memoryCache = NSCache<NSString, UIImage>()

View file

@ -534,64 +534,18 @@ struct MiniPlayerBar: View {
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
// Read directly from @Published properties no timer lag
private var playbackTime: TimeInterval { audioPlayer.currentTime }
private var playbackDuration: TimeInterval { audioPlayer.duration }
private var displayProgress: Double {
if isScrubbing { return scrubPosition }
guard playbackDuration > 0 else { return 0 }
return min(playbackTime / playbackDuration, 1.0)
}
var body: some View {
VStack(spacing: 0) {
// Scrubbable progress bar at top uses album color, generous touch target
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.1))
.frame(height: isScrubbing ? 8 : 3)
Rectangle()
.fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink)
.frame(
width: geo.size.width * displayProgress,
height: isScrubbing ? 8 : 3
)
if isScrubbing {
Circle()
.fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink)
.frame(width: 16, height: 16)
.offset(x: geo.size.width * displayProgress - 8, y: -1)
}
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isScrubbing = true
scrubPosition = min(max(value.location.x / geo.size.width, 0), 1)
}
.onEnded { value in
let pct = min(max(value.location.x / geo.size.width, 0), 1)
audioPlayer.seekToPercent(pct)
// Hold scrubPosition until AVPlayer confirms the seek
// without this the bar snaps back to the pre-seek position
// for up to 250ms while currentTime catches up.
scrubPosition = pct
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isScrubbing = false
}
}
)
}
.frame(height: isScrubbing ? 20 : 14)
.animation(.easeInOut(duration: 0.15), value: isScrubbing)
// Player content
// Progress bar extracted into its own view only it re-evaluates at 10Hz
// when audioPlayer.currentTime changes. The rest of MiniPlayerBar body
// only re-evaluates on song change, play/pause, or color change.
MiniProgressBar(
audioPlayer: audioPlayer,
colorExtractor: colorExtractor,
isScrubbing: $isScrubbing,
scrubPosition: $scrubPosition,
accentPink: accentPink
)
ZStack(alignment: .center) {
// Visualizer behind controls paused when full NowPlaying is open
if VisualizerSettings.shared.enabled && VisualizerSettings.shared.miniPlayerEnabled && !showNowPlaying {
@ -790,6 +744,80 @@ struct DynamicIslandView: View {
}
}
// MARK: - Mini Player Progress Bar (isolated re-evaluation)
//
// Extracted from MiniPlayerBar so only this tiny view re-evaluates when
// audioPlayer.currentTime changes (10x/second from the periodic time observer).
// Before this, the ENTIRE MiniPlayerBar body re-evaluated at 10Hz including
// the GeometryReader, CompactVisualizerView, all ZStack children, and album art
// causing sustained ~128% CPU even with the visualizer off.
//
// The key trick: this view declares its OWN @ObservedObject on audioPlayer so
// SwiftUI's dependency tracking scopes the 10Hz invalidation to THIS view only.
// MiniPlayerBar.body is now only re-evaluated on song change, play/pause, or
// color change not on every currentTime tick.
private struct MiniProgressBar: View {
@ObservedObject var audioPlayer: AudioPlayer
@ObservedObject var colorExtractor: AlbumColorExtractor
@Binding var isScrubbing: Bool
@Binding var scrubPosition: Double
let accentPink: Color
private var displayProgress: Double {
if isScrubbing { return scrubPosition }
guard audioPlayer.duration > 0 else { return 0 }
return min(audioPlayer.currentTime / audioPlayer.duration, 1.0)
}
private var barColor: Color {
colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink
}
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.1))
.frame(height: isScrubbing ? 8 : 3)
Rectangle()
.fill(barColor)
.frame(
width: geo.size.width * displayProgress,
height: isScrubbing ? 8 : 3
)
if isScrubbing {
Circle()
.fill(barColor)
.frame(width: 16, height: 16)
.offset(x: geo.size.width * displayProgress - 8, y: -1)
}
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isScrubbing = true
scrubPosition = min(max(value.location.x / geo.size.width, 0), 1)
}
.onEnded { value in
let pct = min(max(value.location.x / geo.size.width, 0), 1)
audioPlayer.seekToPercent(pct)
scrubPosition = pct
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isScrubbing = false
}
}
)
}
.frame(height: isScrubbing ? 20 : 14)
.animation(.easeInOut(duration: 0.15), value: isScrubbing)
}
}
// MARK: - Keyboard Dismiss
/// Dismiss keyboard from anywhere

View file

@ -3,7 +3,17 @@ import PhotosUI
struct MyMusicView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
// Deliberately NOT @EnvironmentObject / @ObservedObject on AudioPlayer.
// Observing AudioPlayer directly subscribes this view to objectWillChange,
// which fires 10x/second from the currentTime periodic observer. With a
// large allSongs list that causes continuous `initializeWithCopy for
// MyMusicView` in Instruments at ~128% CPU even with nothing on screen.
//
// Instead: track only the one @Published property we actually need in the
// body (currentSong.id for row highlight) via @State + .onReceive. All
// playback actions use AudioPlayer.shared directly no observation required
// for button callbacks.
@State private var currentSongId: String?
@EnvironmentObject var offlineManager: OfflineManager
@Binding var navigateToPlaylistId: String?
@Binding var navigateToAlbumId: String?
@ -182,8 +192,12 @@ struct MyMusicView: View {
}
.task { await loadData() }
.refreshable { refreshData() }
// Reload view data when SyncEngine completes a sync keeps the view
// in sync with the cache without making independent server calls
// Sync currentSongId whenever the playing song changes.
// This is the ONLY AudioPlayer property MyMusicView observes
// currentTime changes at 10Hz do not reach this view at all.
.onReceive(AudioPlayer.shared.$currentSong) { song in
currentSongId = song?.id
}
.onReceive(NotificationCenter.default.publisher(for: .companionLibraryChanged)) { _ in
Task { await loadData() }
}
@ -851,7 +865,7 @@ struct MyMusicView: View {
Button(action: {
if available {
audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index)
AudioPlayer.shared.play(song: song, fromQueue: Array(allSongs), at: index)
}
}) {
HStack(spacing: 12) {
@ -884,7 +898,7 @@ struct MyMusicView: View {
.font(.rowTitle)
.foregroundColor(
!available ? .gray.opacity(0.35) :
audioPlayer.currentSong?.id == song.id ? accentPink : .white
currentSongId == song.id ? accentPink : .white
)
.lineLimit(1)
Text("\(song.artist ?? "") · \(song.album ?? "")")
@ -929,19 +943,19 @@ struct MyMusicView: View {
.padding(.vertical, 6)
}
.contextMenu {
Button(action: { audioPlayer.playNow(song) }) {
Button(action: { AudioPlayer.shared.playNow(song) }) {
Label("Play Now", systemImage: "play.fill")
}
Button(action: { audioPlayer.playNext(song) }) {
Button(action: { AudioPlayer.shared.playNext(song) }) {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
Button(action: { audioPlayer.playLater(song) }) {
Button(action: { AudioPlayer.shared.playLater(song) }) {
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
}
Divider()
Button(action: { audioPlayer.playInstantMix(basedOn: song) }) {
Button(action: { AudioPlayer.shared.playInstantMix(basedOn: song) }) {
Label("Instant Mix", systemImage: "wand.and.stars")
}
@ -1089,7 +1103,7 @@ struct MyMusicView: View {
let albumDetail = try await serverManager.client.getAlbum(id: album.id)
if let songs = albumDetail?.song, !songs.isEmpty {
await MainActor.run {
audioPlayer.play(song: songs[0], fromQueue: songs, at: 0)
AudioPlayer.shared.play(song: songs[0], fromQueue: songs, at: 0)
}
}
} catch { }
@ -1101,7 +1115,7 @@ struct MyMusicView: View {
if let detail = try? await serverManager.client.getAlbum(id: album.id),
let songs = detail.song {
await MainActor.run {
for song in songs.reversed() { audioPlayer.playNext(song) }
for song in songs.reversed() { AudioPlayer.shared.playNext(song) }
}
}
}
@ -1112,7 +1126,7 @@ struct MyMusicView: View {
if let detail = try? await serverManager.client.getAlbum(id: album.id),
let songs = detail.song {
await MainActor.run {
for song in songs { audioPlayer.playLater(song) }
for song in songs { AudioPlayer.shared.playLater(song) }
}
}
}