From 29a91a856dcdd3db61d384475b50524848dbd27b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 01:44:14 +0000 Subject: [PATCH] Confine 20 Hz playback invalidation to leaf views; trim per-render waste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit part 4 — pixel-identical UI, less recomputation: - The Now Playing sheet's transition section read transitionProgress and secondsUntilTransition (both derived from the 20 Hz position writes) inline, re-evaluating the ENTIRE sheet 20x/s whenever it was open. Extracted into a TransitionSection leaf view — same subtree, same transitions — so only the leaf re-evaluates. - TransitionVisualizationView computed TransitionPreview.make on every access (3x+ per frame during live blends); now computed once at init. - SearchView dropped its @Query (which hydrated every Track including beatTimes and kept a live subscription while search was open) for a one-shot title/artist-only fetch. - The custom keyboard's suggestion bar is a leaf view, so keystrokes stop rebuilding the ~40-button key grid. - UpNextView captures upcomingTracks once per body (it allocates a fresh slice per access and was read twice). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013TJoWkqg8bzkGdzxWhWjWP --- App/Continuity/Views/NowPlayingView.swift | 60 +++++++++------- App/Continuity/Views/SearchView.swift | 71 +++++++++++-------- .../Views/TransitionVisualizationView.swift | 16 ++++- App/Continuity/Views/UpNextView.swift | 13 ++-- 4 files changed, 99 insertions(+), 61 deletions(-) diff --git a/App/Continuity/Views/NowPlayingView.swift b/App/Continuity/Views/NowPlayingView.swift index 9a900a0..74f4142 100644 --- a/App/Continuity/Views/NowPlayingView.swift +++ b/App/Continuity/Views/NowPlayingView.swift @@ -384,31 +384,10 @@ struct NowPlayingView: View { /// The flagship transition made visible: a live blend graph while a transition is in flight, /// or a preview of the next scheduled blend (with a countdown) when one is coming up. - @ViewBuilder private var transitionSection: some View { - if player.isTransitioning, let next = player.incomingTrack, let current = player.currentTrack { - TransitionVisualizationView( - settings: player.transitionSettings, - outgoing: current, - incoming: next, - isLive: true, - liveProgress: min(max(player.transitionProgress, 0), 1), - secondsUntil: nil - ) - .padding(.horizontal, 24) - .transition(.opacity) - } else if let current = player.currentTrack, let next = player.upcomingTracks.first { - TransitionVisualizationView( - settings: player.transitionSettings, - outgoing: current, - incoming: next, - isLive: false, - liveProgress: 0, - secondsUntil: player.secondsUntilTransition - ) - .padding(.horizontal, 24) - .transition(.opacity) - } - } + /// A LEAF view on purpose: it reads `transitionProgress` / `secondsUntilTransition`, both + /// derived from the 20 Hz `position` writes — inlined into the sheet body (as it used to + /// be) those reads re-evaluated the ENTIRE sheet 20x/s whenever it was open. + private var transitionSection: some View { TransitionSection() } // MARK: Scrubber (sheet only) @@ -472,3 +451,34 @@ private struct ScrubberBar: View { .padding(.horizontal, 32) } } + +/// Leaf: the sheet's only reader of the 20 Hz-derived blend state (see `transitionSection`). +private struct TransitionSection: View { + @Environment(Player.self) private var player + + var body: some View { + if player.isTransitioning, let next = player.incomingTrack, let current = player.currentTrack { + TransitionVisualizationView( + settings: player.transitionSettings, + outgoing: current, + incoming: next, + isLive: true, + liveProgress: min(max(player.transitionProgress, 0), 1), + secondsUntil: nil + ) + .padding(.horizontal, 24) + .transition(.opacity) + } else if let current = player.currentTrack, let next = player.upcomingTracks.first { + TransitionVisualizationView( + settings: player.transitionSettings, + outgoing: current, + incoming: next, + isLive: false, + liveProgress: 0, + secondsUntil: player.secondsUntilTransition + ) + .padding(.horizontal, 24) + .transition(.opacity) + } + } +} diff --git a/App/Continuity/Views/SearchView.swift b/App/Continuity/Views/SearchView.swift index 6ffa470..3947e58 100644 --- a/App/Continuity/Views/SearchView.swift +++ b/App/Continuity/Views/SearchView.swift @@ -156,7 +156,6 @@ struct SearchView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @Environment(PreparationQueue.self) private var prepQueue - @Query private var allTracks: [Track] @State private var model = CatalogSearchModel() @@ -168,7 +167,13 @@ struct SearchView: View { } .background(Color(uiColor: .systemGroupedBackground)) .task { - model.seedVocabulary(titles: allTracks.map(\.title) + allTracks.map(\.artist)) + // One-shot seed, fetching only the two strings we read — @Query here hydrated + // every Track (beatTimes arrays included) and kept a live subscription re-firing + // on any library change for as long as search stayed open. + var descriptor = FetchDescriptor() + descriptor.propertiesToFetch = [\.title, \.artist] + let tracks = (try? modelContext.fetch(descriptor)) ?? [] + model.seedVocabulary(titles: tracks.map(\.title) + tracks.map(\.artist)) } } @@ -398,7 +403,9 @@ private struct MusicKeyboardView: View { var body: some View { VStack(spacing: 7) { - suggestionBar + // Leaf view: suggestions change on every keystroke; inlined, they re-built the + // entire ~40-button key grid per keypress instead of just this bar. + SuggestionBar(model: model, haptic: haptic) ForEach(showNumbers ? Self.numberRows : Self.letterRows, id: \.self) { row in keyRow(row) } @@ -410,32 +417,6 @@ private struct MusicKeyboardView: View { .background(.regularMaterial) } - private var suggestionBar: some View { - HStack(spacing: 6) { - let suggestions = model.suggestions - if suggestions.isEmpty { - // Fixed height so the keyboard never jumps as suggestions come and go. - Color.clear.frame(height: 32) - } else { - ForEach(suggestions, id: \.self) { word in - Button { - haptic.impactOccurred() - model.accept(suggestion: word) - } label: { - Text(word) - .font(.subheadline) - .lineLimit(1) - .frame(maxWidth: .infinity) - .frame(height: 32) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - } - .padding(.horizontal, 4) - } - private func keyRow(_ characters: String) -> some View { HStack(spacing: 5) { ForEach(Array(characters), id: \.self) { character in @@ -494,3 +475,35 @@ private struct MusicKeyboardView: View { .buttonRepeatBehavior(symbol == "delete.left" ? .enabled : .disabled) } } + +/// Leaf: the keyboard's only per-keystroke invalidation surface (see MusicKeyboardView.body). +private struct SuggestionBar: View { + let model: CatalogSearchModel + let haptic: UIImpactFeedbackGenerator + + var body: some View { + HStack(spacing: 6) { + let suggestions = model.suggestions + if suggestions.isEmpty { + // Fixed height so the keyboard never jumps as suggestions come and go. + Color.clear.frame(height: 32) + } else { + ForEach(suggestions, id: \.self) { word in + Button { + haptic.impactOccurred() + model.accept(suggestion: word) + } label: { + Text(word) + .font(.subheadline) + .lineLimit(1) + .frame(maxWidth: .infinity) + .frame(height: 32) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 4) + } +} diff --git a/App/Continuity/Views/TransitionVisualizationView.swift b/App/Continuity/Views/TransitionVisualizationView.swift index 7a4da95..bdec08d 100644 --- a/App/Continuity/Views/TransitionVisualizationView.swift +++ b/App/Continuity/Views/TransitionVisualizationView.swift @@ -17,8 +17,20 @@ struct TransitionVisualizationView: View { let liveProgress: Double let secondsUntil: TimeInterval? - private var preview: TransitionPreview { - TransitionPreview.make( + /// Computed once at construction: `body` reads it in several places (graph + chips), and + /// as a computed property `TransitionPreview.make` re-ran on every access — 3×+ per frame + /// during live blends. Pure function of the init inputs, so a stored value is identical. + private let preview: TransitionPreview + + init(settings: TransitionSettings, outgoing: Track, incoming: Track, + isLive: Bool, liveProgress: Double, secondsUntil: TimeInterval?) { + self.settings = settings + self.outgoing = outgoing + self.incoming = incoming + self.isLive = isLive + self.liveProgress = liveProgress + self.secondsUntil = secondsUntil + self.preview = TransitionPreview.make( curve: settings.curve, duration: settings.durationSeconds, outgoingBPM: outgoing.bpm, diff --git a/App/Continuity/Views/UpNextView.swift b/App/Continuity/Views/UpNextView.swift index eb6e475..5f95ba5 100644 --- a/App/Continuity/Views/UpNextView.swift +++ b/App/Continuity/Views/UpNextView.swift @@ -13,12 +13,15 @@ struct UpNextView: View { @AppStorage("flowMode.v1") private var flowMode = false var body: some View { - NavigationStack { + // Capture once: upcomingTracks allocates a fresh array slice per access, and body + // read it twice (emptiness check + list). + let upcoming = player.upcomingTracks + return NavigationStack { Group { - if player.upcomingTracks.isEmpty { + if upcoming.isEmpty { ContentUnavailableView("Nothing up next", systemImage: "list.bullet") } else { - queueList + queueList(upcoming) } } .navigationTitle("Up Next") @@ -47,10 +50,10 @@ struct UpNextView: View { } } - private var queueList: some View { + private func queueList(_ upcoming: [Track]) -> some View { List { Section { - ForEach(player.upcomingTracks) { track in + ForEach(upcoming) { track in row(track) } .onMove { player.moveUpcoming(fromOffsets: $0, toOffset: $1) }