Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ final class NowPlayingBridge {
artworkURL = track.artworkURL
guard let url = track.artworkURL else { return }

artworkTask = Task { [weak self] in
// Lock-screen art is eventually-visible, not latency-critical — utility priority keeps
// the fetch from competing with blend/tick work under load.
artworkTask = Task(priority: .utility) { [weak self] in
guard let (data, _) = try? await URLSession.shared.data(from: url),
let image = UIImage(data: data), !Task.isCancelled else { return }
let art = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ public struct PersistedPlaybackState: Codable, Equatable {
public enum PlaybackStateStore {
private static let key = "playbackState.v1"

// Reused across the ~5 s periodic saves — constructing a fresh coder per call is pure
// allocation churn on the main thread.
private static let encoder = JSONEncoder()
private static let decoder = JSONDecoder()

public static func load() -> PersistedPlaybackState? {
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(PersistedPlaybackState.self, from: data)
return try? decoder.decode(PersistedPlaybackState.self, from: data)
}

static func save(_ state: PersistedPlaybackState) {
guard let data = try? JSONEncoder().encode(state) else { return }
guard let data = try? encoder.encode(state) else { return }
UserDefaults.standard.set(data, forKey: key)
}

Expand Down
6 changes: 5 additions & 1 deletion Packages/ContinuityKit/Sources/Playback/Player+History.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ extension Player {
skipsRemaining: skipsRemaining
)
guard !queue.isEmpty else { return }
// queue.map(\.id) reads a SwiftData property per queued track — wasteful for the ~5 s
// periodic save where the queue hasn't changed. The cache invalidates on queue mutation
// (didSet in Player.swift), so the periodic save reuses the same array.
if cachedQueueIDs == nil { cachedQueueIDs = queue.map(\.id) }
PlaybackStateStore.save(PersistedPlaybackState(
queueTrackIDs: queue.map(\.id),
queueTrackIDs: cachedQueueIDs ?? queue.map(\.id),
currentIndex: currentIndex,
positionSeconds: position,
skipsRemaining: skipsRemaining,
Expand Down
30 changes: 25 additions & 5 deletions Packages/ContinuityKit/Sources/Playback/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,15 @@ final class AudioStack {
@Observable
public final class Player {
// MARK: Observable state
var queue: [Track] = []
var queue: [Track] = [] {
didSet { cachedQueueIDs = nil } // persistState() rebuilds the ID array lazily
}
/// Cached `queue.map(\.id)` for the periodic persist — each `.id` is a SwiftData read,
/// so re-mapping an unchanged queue every ~5 s was pure managed-object churn.
@ObservationIgnored var cachedQueueIDs: [UUID]?
/// Cached `audibleEndSeconds` of the loaded track (refreshed ~1 Hz in `tick()`), so the
/// 20 Hz `effectiveEndSeconds` reads don't hit SwiftData property machinery every tick.
@ObservationIgnored var trimCache: (track: Track, end: Double?)?
var currentIndex = 0
public internal(set) var isPlaying = false
public internal(set) var isTransitioning = false
Expand Down Expand Up @@ -306,8 +314,12 @@ public final class Player {
func startTimer() {
stopTimer()
// 20 Hz: smooth enough for the volume ramp and the scrubber.
// The timer fires on RunLoop.main, i.e. already on the main actor — run tick()
// synchronously (same pattern as the audio-session notification handlers). Wrapping
// it in Task {} allocated and re-queued 20 tasks/second, deferring each tick to a
// later scheduling slot — measurable churn exactly when the device is under load.
let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in
Task { @MainActor in self?.tick() }
MainActor.assumeIsolated { self?.tick() }
}
RunLoop.main.add(timer, forMode: .common)
displayTimer = timer
Expand All @@ -327,9 +339,11 @@ public final class Player {
let full = duration
// Pre-audio the staged track carries the same trim metadata as the loaded deck would.
let track = audio?.current.track ?? currentTrack
guard transitionSettings.trimSilenceEnabled,
let audibleEnd = track?.audibleEndSeconds,
audibleEnd > 1, audibleEnd < full else { return full }
guard transitionSettings.trimSilenceEnabled, let track else { return full }
// Hot path (20 Hz from tick + UI): use the ~1 Hz cache instead of a SwiftData read.
// Cache misses (pre-audio, track just changed) fall through to the direct read.
let end = trimCache?.track === track ? trimCache?.end : track.audibleEndSeconds
guard let audibleEnd = end, audibleEnd > 1, audibleEnd < full else { return full }
return audibleEnd
}

Expand Down Expand Up @@ -362,6 +376,12 @@ public final class Player {
ticksSincePersist = 0
persistState()
}
// Refresh the trim cache on track change and ~1 Hz: audibleEndSeconds only ever moves
// when the silence scan backfills it (minutes before it matters at track end), so a
// ≤1 s pickup delay is imperceptible — and it keeps SwiftData reads off the 20 Hz path.
if let track = audio.current.track, trimCache?.track !== track || memTicks % 20 == 0 {
trimCache = (track, track.audibleEndSeconds)
}
let dur = effectiveEndSeconds
if isTransitioning {
let plan = TransitionPlan(
Expand Down
Loading