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
60 changes: 35 additions & 25 deletions App/Continuity/Views/NowPlayingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
}
}
71 changes: 42 additions & 29 deletions App/Continuity/Views/SearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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<Track>()
descriptor.propertiesToFetch = [\.title, \.artist]
let tracks = (try? modelContext.fetch(descriptor)) ?? []
model.seedVocabulary(titles: tracks.map(\.title) + tracks.map(\.artist))
}
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
16 changes: 14 additions & 2 deletions App/Continuity/Views/TransitionVisualizationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions App/Continuity/Views/UpNextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) }
Expand Down
Loading