memory improvements
This commit is contained in:
parent
0730fa11f8
commit
85c85c2090
5 changed files with 601 additions and 368 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue