From 0e6bcb33fc7259b2899eeb3bb6d546d311bb6734 Mon Sep 17 00:00:00 2001 From: sanylax_macbook Date: Tue, 21 Jul 2026 02:45:49 -0400 Subject: [PATCH] Transition voting: rate a blend, adapt that pair's next one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thumbs up/down on the transition you just heard. Votes are keyed by the *directional* track pair (A→B is rated separately from B→A — the blend isn't symmetric) and snapshot the settings that actually produced the blend, so a downvote on an already-simplified transition is distinguishable from one on the user's full settings. Repeated downvotes back that pair's blend off a step at a time; upvotes let it climb back. - TransitionVote (@Model): plain UUID references rather than SwiftData relationships, so votes survive a track being deleted and re-imported without cascade rules getting involved. - TransitionFeedback.simplificationLevel (ContinuityCore): pure vote-history → level math, unit-tested and Linux-portable like the rest of the DSP//policy code. - TransitionSettings.simplified(level:): progressively drops beatmatching, bass swap, and harmonic mixing, and shortens the blend. - Player+Voting: adaptedTransitionSettings is cached per pair, because the scheduling check runs at 20 Hz and the history lookup is a SwiftData fetch. Cache is invalidated on a new vote and on any settings change. - clearTransitionState drops the votable record: an aborted blend was never fully heard, so there's nothing to rate. - RootView supplies vote history via the same hook pattern as onQueueExhausted; TransitionVoteBar appears in both the compact and sheet Now Playing layouts. Reassembled from work that was split across an unpushed local commit and a stash, then rebased onto main (clearTransitionState needed both the new pitch-settle teardown and the vote teardown). ContinuityCore: 175 tests pass. App target builds for the simulator. Co-Authored-By: Claude Opus 4.8 --- App/Continuity/Views/ContinuityApp.swift | 2 +- App/Continuity/Views/NowPlayingView.swift | 8 ++ App/Continuity/Views/RootView.swift | 9 ++ App/Continuity/Views/TransitionVoteBar.swift | 94 +++++++++++++++++++ .../ContinuityCore/TransitionFeedback.swift | 28 ++++++ .../TransitionFeedbackTests.swift | 54 +++++++++++ .../Sources/Domain/TransitionSettings.swift | 24 +++++ .../Sources/Domain/TransitionVote.swift | 53 +++++++++++ .../Sources/Playback/Player+Transitions.swift | 41 ++++++-- .../Sources/Playback/Player+Voting.swift | 54 +++++++++++ .../Sources/Playback/Player.swift | 29 +++++- 11 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 App/Continuity/Views/TransitionVoteBar.swift create mode 100644 Packages/ContinuityCore/Sources/ContinuityCore/TransitionFeedback.swift create mode 100644 Packages/ContinuityCore/Tests/ContinuityCoreTests/TransitionFeedbackTests.swift create mode 100644 Packages/ContinuityKit/Sources/Domain/TransitionVote.swift create mode 100644 Packages/ContinuityKit/Sources/Playback/Player+Voting.swift diff --git a/App/Continuity/Views/ContinuityApp.swift b/App/Continuity/Views/ContinuityApp.swift index 1be724e..753af4c 100644 --- a/App/Continuity/Views/ContinuityApp.swift +++ b/App/Continuity/Views/ContinuityApp.swift @@ -13,7 +13,7 @@ struct ContinuityApp: App { init() { do { - let schema = Schema([Playlist.self, Track.self]) + let schema = Schema([Playlist.self, Track.self, TransitionVote.self]) // Pin the store to the app container. `groupContainer` defaults to `.automatic`, // which (with our share-extension app group entitlement) put SwiftData in the // group container — unused by the extension, noisy on first launch, and desynced diff --git a/App/Continuity/Views/NowPlayingView.swift b/App/Continuity/Views/NowPlayingView.swift index 74f4142..0213075 100644 --- a/App/Continuity/Views/NowPlayingView.swift +++ b/App/Continuity/Views/NowPlayingView.swift @@ -72,6 +72,12 @@ struct NowPlayingView: View { } .padding(.bottom, 12) } + // Transient thumbs for the blend in flight (or just finished) — floats above the Up + // Next chevron so the centered column never reflows when it appears. + .overlay(alignment: .bottom) { + TransitionVoteBar() + .padding(.bottom, 72) + } } // MARK: Sheet layout (full detail) @@ -93,6 +99,8 @@ struct NowPlayingView: View { transitionSection + TransitionVoteBar() + scrubber transport diff --git a/App/Continuity/Views/RootView.swift b/App/Continuity/Views/RootView.swift index b144755..81ce29e 100644 --- a/App/Continuity/Views/RootView.swift +++ b/App/Continuity/Views/RootView.swift @@ -76,6 +76,15 @@ struct RootView: View { player.onUpcomingTracks = { [weak prepQueue] tracks in prepQueue?.ensureStems(for: tracks, in: modelContext) } + // Transition voting: the engine asks for a pair's thumb history when scheduling + // its next blend (cached per pair, so this fetch is rare, not per-tick). + player.transitionVoteHistory = { fromID, toID in + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.fromTrackID == fromID && $0.toTrackID == toID }, + sortBy: [SortDescriptor(\.createdAt)] + ) + return ((try? modelContext.fetch(descriptor)) ?? []).map(\.isUpvote) + } // Natural queue exhaustion loops playback into the listening history — resolve // the persisted IDs to live tracks in order; deleted tracks simply drop out. player.onQueueExhausted = { ids in diff --git a/App/Continuity/Views/TransitionVoteBar.swift b/App/Continuity/Views/TransitionVoteBar.swift new file mode 100644 index 0000000..a6ea0ac --- /dev/null +++ b/App/Continuity/Views/TransitionVoteBar.swift @@ -0,0 +1,94 @@ +import SwiftUI +import SwiftData +import Playback +import Domain + +/// Thumbs-up/down for the blend that's playing (or just played). Appears when a transition +/// starts, lingers a few seconds after it completes, then fades; voting collapses it to a brief +/// confirmation. Votes persist per directional track pair and feed `Player`'s adaptation — a +/// downvoted pair blends simpler next time, an upvoted one climbs back toward full settings. +/// +/// A leaf on purpose: it observes only `votableTransition` (which changes once per blend), never +/// the 20 Hz `position`/`transitionProgress` writes, so it can't drag parent bodies into the +/// tick churn the playback jetsam RCA banned. +struct TransitionVoteBar: View { + @Environment(Player.self) private var player + @Environment(\.modelContext) private var modelContext + + /// Record just voted on — shows the confirmation state before hiding. + @State private var votedRecordID: UUID? + /// Records dismissed (auto-hide timeout or post-vote) — keyed by ID so a NEW blend reappears. + @State private var hiddenRecordID: UUID? + + /// How long the bar stays up after the blend completes (unvoted). + private static let lingerSeconds: Double = 10 + /// How long the "thanks" state shows before the bar hides. + private static let confirmSeconds: Double = 1.2 + + var body: some View { + if let record = player.votableTransition, record.id != hiddenRecordID { + HStack(spacing: 12) { + if votedRecordID == record.id { + Label("Noted", systemImage: "checkmark") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white.opacity(0.85)) + .transition(.opacity) + } else { + Text("Rate blend") + .font(.footnote.weight(.medium)) + .foregroundStyle(.white.opacity(0.7)) + thumb("hand.thumbsup.fill", accessibility: "Good transition") { + vote(record, isUpvote: true) + } + thumb("hand.thumbsdown.fill", accessibility: "Bad transition") { + vote(record, isUpvote: false) + } + } + } + .padding(.horizontal, 14) + .frame(height: 34) + .continuityGlass(cornerRadius: 20) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + // Restart the linger clock when the blend completes (completedAt flips nil → Date). + .task(id: record.completedAt) { + guard record.completedAt != nil else { return } + try? await Task.sleep(for: .seconds(Self.lingerSeconds)) + withAnimation(.easeOut(duration: 0.4)) { hiddenRecordID = record.id } + } + .animation(.easeInOut(duration: 0.25), value: votedRecordID) + } + } + + private func thumb(_ system: String, accessibility: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: system) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibility) + } + + private func vote(_ record: TransitionRecord, isUpvote: Bool) { + let vote = TransitionVote( + fromTrackID: record.fromTrackID, + toTrackID: record.toTrackID, + isUpvote: isUpvote, + settings: record.settings, + simplificationLevel: record.simplificationLevel + ) + modelContext.insert(vote) + try? modelContext.save() + // The pair's cached adaptation is stale the moment the vote lands. + player.invalidateTransitionAdaptation() + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + votedRecordID = record.id + Task { + try? await Task.sleep(for: .seconds(Self.confirmSeconds)) + withAnimation(.easeOut(duration: 0.4)) { hiddenRecordID = record.id } + } + } +} diff --git a/Packages/ContinuityCore/Sources/ContinuityCore/TransitionFeedback.swift b/Packages/ContinuityCore/Sources/ContinuityCore/TransitionFeedback.swift new file mode 100644 index 0000000..877b2eb --- /dev/null +++ b/Packages/ContinuityCore/Sources/ContinuityCore/TransitionFeedback.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Pure logic for transition voting: turns a track-pair's thumbs-up/down history into a +/// *simplification level* the engine applies to that pair's next blend. The idea: when a fancy +/// transition (beatmatched, key-shifted, vocal-aware) gets downvoted, the next attempt backs +/// off toward a plain short crossfade one notch at a time; upvotes climb back toward the user's +/// configured settings. +public enum TransitionFeedback { + /// Levels are 0 (user's settings untouched) through `maxSimplificationLevel` (minimal, + /// radio-style fade). What each notch disables is the engine's mapping, not ours — this + /// type only decides *how far* to back off. + public static let maxSimplificationLevel = 3 + + /// Only the most recent votes count, so a pair the user once hated can climb back to full + /// settings after a few upvotes instead of dragging years of history around. + public static let voteWindow = 8 + + /// The simplification level for a pair, from its vote history. + /// - Parameter votes: chronological (oldest first) votes for one directional track pair; + /// `true` = thumbs up. + /// - Returns: `max(downs - ups, 0)` over the last `voteWindow` votes, clamped to + /// `maxSimplificationLevel`. + public static func simplificationLevel(votes: [Bool]) -> Int { + let recent = votes.suffix(voteWindow) + let net = recent.reduce(0) { $0 + ($1 ? -1 : 1) } + return min(max(net, 0), maxSimplificationLevel) + } +} diff --git a/Packages/ContinuityCore/Tests/ContinuityCoreTests/TransitionFeedbackTests.swift b/Packages/ContinuityCore/Tests/ContinuityCoreTests/TransitionFeedbackTests.swift new file mode 100644 index 0000000..1412a2a --- /dev/null +++ b/Packages/ContinuityCore/Tests/ContinuityCoreTests/TransitionFeedbackTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import ContinuityCore + +final class TransitionFeedbackTests: XCTestCase { + + func testNoVotesMeansNoSimplification() { + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: []), 0) + } + + func testUpvotesNeverSimplify() { + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: [true]), 0) + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: [true, true, true]), 0) + } + + func testEachDownvoteAddsALevel() { + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: [false]), 1) + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: [false, false]), 2) + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: [false, false, false]), 3) + } + + func testLevelClampsAtMax() { + let votes = Array(repeating: false, count: 6) + XCTAssertEqual( + TransitionFeedback.simplificationLevel(votes: votes), + TransitionFeedback.maxSimplificationLevel + ) + } + + func testUpvotesCancelDownvotes() { + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: [false, true]), 0) + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: [false, false, true]), 1) + // Net can't go below zero: extra upvotes don't bank credit against future downvotes + // beyond the window's contents. + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: [true, true, false]), 0) + } + + func testOldVotesFallOutOfTheWindow() { + // 8 old downvotes followed by `voteWindow` upvotes: the window sees only upvotes. + let votes = Array(repeating: false, count: 8) + Array(repeating: true, count: TransitionFeedback.voteWindow) + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: votes), 0) + } + + func testRecoveryOneNotchAtATime() { + // A hated pair (3 downs) improves with each upvote. + var votes: [Bool] = [false, false, false] + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: votes), 3) + votes.append(true) + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: votes), 2) + votes.append(true) + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: votes), 1) + votes.append(true) + XCTAssertEqual(TransitionFeedback.simplificationLevel(votes: votes), 0) + } +} diff --git a/Packages/ContinuityKit/Sources/Domain/TransitionSettings.swift b/Packages/ContinuityKit/Sources/Domain/TransitionSettings.swift index 0e27802..9f8798d 100644 --- a/Packages/ContinuityKit/Sources/Domain/TransitionSettings.swift +++ b/Packages/ContinuityKit/Sources/Domain/TransitionSettings.swift @@ -103,6 +103,30 @@ extension TransitionSettings { } } +extension TransitionSettings { + /// The engine-side mapping for `TransitionFeedback.simplificationLevel`: each notch backs a + /// downvoted pair's blend off toward a plain short fade. The ladder drops the most audible + /// artifacts first — key shifting, then tempo warping, then everything but the crossfade. + /// Level 0 (and anything unrecognized) returns the settings unchanged. + public func simplified(level: Int) -> TransitionSettings { + var s = self + if level >= 1 { + s.harmonicMixingEnabled = false + s.durationSeconds = min(s.durationSeconds, 8) + } + if level >= 2 { + s.beatmatchEnabled = false + s.durationSeconds = min(s.durationSeconds, 5) + } + if level >= 3 { + s.bassSwapEnabled = false + s.vocalMode = .hardSwap + s.durationSeconds = min(s.durationSeconds, 2) + } + return s + } +} + /// A named bundle of transition settings for one-tap application in the settings UI. public struct TransitionPreset: Identifiable, Equatable, Sendable { public var id: String { name } diff --git a/Packages/ContinuityKit/Sources/Domain/TransitionVote.swift b/Packages/ContinuityKit/Sources/Domain/TransitionVote.swift new file mode 100644 index 0000000..bae0828 --- /dev/null +++ b/Packages/ContinuityKit/Sources/Domain/TransitionVote.swift @@ -0,0 +1,53 @@ +import Foundation +import SwiftData + +/// One thumbs-up/down on a transition the user just heard. Votes are keyed by the *directional* +/// track pair (A→B is rated separately from B→A — the blend is not symmetric), and each vote +/// snapshots the settings that actually produced the blend, so a downvote on an adapted (already +/// simplified) transition is distinguishable from one on the user's full settings. +/// +/// Plain UUID references instead of SwiftData relationships on purpose: votes must survive a +/// track being deleted and re-imported mid-history without cascade rules getting involved, and +/// the adaptation lookup only ever needs the IDs. +@Model +public final class TransitionVote { + @Attribute(.unique) public var id: UUID + public var createdAt: Date + /// The outgoing track of the rated blend. + public var fromTrackID: UUID + /// The incoming track of the rated blend. + public var toTrackID: UUID + public var isUpvote: Bool + + // Snapshot of the blend that was rated. + public var durationSeconds: Double + public var beatmatchEnabled: Bool + public var bassSwapEnabled: Bool + public var harmonicMixingEnabled: Bool + public var vocalModeRaw: String + /// The simplification level the engine had already applied to this blend (0 = the user's + /// settings as configured). + public var simplificationLevel: Int + + public init( + id: UUID = UUID(), + createdAt: Date = .now, + fromTrackID: UUID, + toTrackID: UUID, + isUpvote: Bool, + settings: TransitionSettings, + simplificationLevel: Int + ) { + self.id = id + self.createdAt = createdAt + self.fromTrackID = fromTrackID + self.toTrackID = toTrackID + self.isUpvote = isUpvote + self.durationSeconds = settings.durationSeconds + self.beatmatchEnabled = settings.beatmatchEnabled + self.bassSwapEnabled = settings.bassSwapEnabled + self.harmonicMixingEnabled = settings.harmonicMixingEnabled + self.vocalModeRaw = settings.vocalMode.rawValue + self.simplificationLevel = simplificationLevel + } +} diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift index 4c8fb37..7966136 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift @@ -16,6 +16,28 @@ extension Player { // Automatic blends arrive from tick(); skip blends may materialize a staged session first. // Funnel both through the same lazy stack accessor. let audio = ensureAudioStack() + + // Vote adaptation: a downvoted pair runs a simplified blend (see Player+Voting). The + // adapted settings drive every effect decision below and the mid-blend shaping in + // tick(); `duration` stays the caller's (tick already passes the adapted duration, + // and skip blends keep their fixed 5 s). + let outgoingTrack = audio.current.track ?? currentTrack + let (blend, level) = adaptedTransitionSettings(from: outgoingTrack, to: queue[index]) + activeBlendSettings = blend + var blendForRecord = blend + blendForRecord.durationSeconds = duration + if let outgoingTrack { + votableTransition = TransitionRecord( + id: UUID(), + fromTrackID: outgoingTrack.id, + toTrackID: queue[index].id, + fromTitle: outgoingTrack.title, + toTitle: queue[index].title, + settings: blendForRecord, + simplificationLevel: level, + completedAt: nil + ) + } let incoming = audio.idle incoming.load(queue[index]) applyLoudness(to: incoming) @@ -32,7 +54,7 @@ extension Player { // like the harmonic pitch shift below). var rate = 1.0 incomingRate = 1 - if transitionSettings.beatmatchEnabled, + if blend.beatmatchEnabled, let outBPM = audio.current.track?.bpm, let inBPM = queue[index].bpm, let matched = BeatMath.matchRate(incomingBPM: inBPM, outgoingBPM: outBPM * currentRate) { @@ -45,7 +67,7 @@ extension Player { // phase-locking the two grids through the blend (the "you won't notice" bit). Needs a beat // grid on both tracks; declines gracefully — and only when the seek lands — leaving the // incoming at its start otherwise. - if transitionSettings.beatmatchEnabled, + if blend.beatmatchEnabled, let outBeats = audio.current.track?.beatTimes, !outBeats.isEmpty, let offset = BeatMath.incomingStartOffset( outgoingPosition: outgoingPosition, @@ -60,7 +82,7 @@ extension Player { // Gapless: if beat-alignment didn't already seek, skip the incoming track's leading // silence so the blend brings in audio, not dead air. (Beat grids start at the first // onsets, so an aligned seek is already past any leading silence.) - if transitionSettings.trimSilenceEnabled, incomingStartOffset == 0, + if blend.trimSilenceEnabled, incomingStartOffset == 0, let audibleStart = queue[index].audibleStartSeconds, audibleStart > 0.05, incoming.seekRealFile(to: audibleStart) { incomingStartOffset = audibleStart @@ -71,7 +93,7 @@ extension Player { // the shift persists for the whole track — Deck.load resets it on the next load). Compare // against the outgoing track's EFFECTIVE key: it may itself be playing shifted. incomingPitchShiftSemitones = 0 - if transitionSettings.harmonicMixingEnabled, + if blend.harmonicMixingEnabled, let outKey = audio.current.track?.camelotCode.flatMap(Camelot.parse), let inKey = queue[index].camelotCode.flatMap(Camelot.parse), let shift = HarmonicMix.pitchShiftSemitones( @@ -85,7 +107,7 @@ extension Player { // Vocal-aware setup: for instrumental-overlap / hard-swap the incoming vocals start silent // (they enter later); for ducking they ride in with the track. if incoming.hasStems { - switch transitionSettings.vocalMode { + switch blend.vocalMode { case .duck: incoming.vocalsGain = 1 case .instrumentalOverlap, .hardSwap: incoming.vocalsGain = 0 } @@ -93,7 +115,7 @@ extension Player { // Start the incoming with its low end cut so the bass-swap has something to ramp up from // (avoids a low-end blip before the first tick). - if transitionSettings.bassSwapEnabled { incoming.bassGainDB = bassSwapCutDB } + if blend.bassSwapEnabled { incoming.bassGainDB = bassSwapCutDB } incoming.play() transitionTargetIndex = index @@ -106,7 +128,7 @@ extension Player { /// both decks have stems. func applyVocalHandling(progress: Double) { guard let audio else { return } - switch transitionSettings.vocalMode { + switch blendSettings.vocalMode { case .duck: // Outgoing vocals fade out over the first ~70% of the blend; incoming vocals ride in. audio.current.vocalsGain = Float(max(0, 1 - progress / 0.7)) @@ -161,6 +183,9 @@ extension Player { activeTransitionDurationSeconds = 0 isUserInitiatedSkipTransition = false transitionProgress = 0 + activeBlendSettings = nil + // Timestamp the record: the blend is now rateable as "the transition you just heard". + votableTransition?.completedAt = Date() persistState() notifyUpcoming() } @@ -192,6 +217,8 @@ extension Player { incomingPitchShiftSemitones = 0 incomingRate = 1 transitionProgress = 0 + activeBlendSettings = nil + votableTransition = nil // an aborted blend was never fully heard — nothing to rate cancelPitchSettle() } diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Voting.swift b/Packages/ContinuityKit/Sources/Playback/Player+Voting.swift new file mode 100644 index 0000000..d799773 --- /dev/null +++ b/Packages/ContinuityKit/Sources/Playback/Player+Voting.swift @@ -0,0 +1,54 @@ +import Foundation +import Domain +import ContinuityCore + +/// A blend the user can rate: created when a transition starts, completed (timestamped) when the +/// decks swap. The UI shows thumbs while the record exists; the app layer persists the vote and +/// the engine adapts the pair's next blend from the accumulated history. +public struct TransitionRecord: Equatable, Sendable, Identifiable { + public let id: UUID + public let fromTrackID: UUID + public let toTrackID: UUID + public let fromTitle: String + public let toTitle: String + /// The settings the blend actually ran with (post-adaptation). + public let settings: TransitionSettings + /// How far the engine had already backed this pair off (0 = user's settings). + public let simplificationLevel: Int + /// Set when the blend finishes; nil while it's still in flight. + public var completedAt: Date? +} + +extension Player { + /// Vote history for a directional track pair (chronological, `true` = thumbs up), supplied + /// by the app layer from SwiftData — same hook pattern as `onQueueExhausted`. nil (unwired) + /// disables adaptation. + /// Set via `configureTransitionVoting` so the adaptation cache can't serve stale history. + public var transitionVoteHistory: ((UUID, UUID) -> [Bool])? { + get { transitionVoteHistoryStorage } + set { transitionVoteHistoryStorage = newValue; adaptationCache = nil } + } + + /// The settings the next blend from `from` into `to` should run with, given the pair's vote + /// history. Cached per pair because the scheduling check runs at 20 Hz and the history lookup + /// is a SwiftData fetch. + func adaptedTransitionSettings(from: Track?, to: Track) -> (settings: TransitionSettings, level: Int) { + guard let from, let historyProvider = transitionVoteHistoryStorage else { + return (transitionSettings, 0) + } + if let cached = adaptationCache, + cached.fromID == from.id, cached.toID == to.id, cached.base == transitionSettings { + return (cached.settings, cached.level) + } + let level = TransitionFeedback.simplificationLevel(votes: historyProvider(from.id, to.id)) + let settings = level > 0 ? transitionSettings.simplified(level: level) : transitionSettings + adaptationCache = (from.id, to.id, transitionSettings, settings, level) + return (settings, level) + } + + /// Drops the cached per-pair adaptation so the next scheduling tick re-reads vote history. + /// The app layer calls this after persisting a new vote. + public func invalidateTransitionAdaptation() { + adaptationCache = nil + } +} diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index 6e863e3..d3b3733 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player.swift @@ -177,6 +177,22 @@ public final class Player { /// Reset wherever the current track changes (startCurrentFresh, finishTransition, prepare). var queueRefillAttempted = false + // MARK: Transition voting + + /// The blend currently (or most recently) available for a thumbs-up/down. Set when a + /// transition starts, timestamped on completion, cleared on cancel; the UI decides how long + /// a completed record stays votable. Changes rarely — safe for non-leaf views to observe. + public internal(set) var votableTransition: TransitionRecord? + /// The (possibly vote-adapted) settings the in-flight blend runs with. Falls back to the + /// user's configured settings outside a blend. + @ObservationIgnored var activeBlendSettings: TransitionSettings? + /// Settings the in-flight blend uses everywhere `transitionSettings` used to be read mid-blend. + var blendSettings: TransitionSettings { activeBlendSettings ?? transitionSettings } + /// Backing storage for `transitionVoteHistory` (see Player+Voting). + @ObservationIgnored var transitionVoteHistoryStorage: ((UUID, UUID) -> [Bool])? + /// Per-pair adaptation cache: (pair, base settings it was derived from, result). + @ObservationIgnored var adaptationCache: (fromID: UUID, toID: UUID, base: TransitionSettings, settings: TransitionSettings, level: Int)? + /// How far ahead stems are prepared. At ~2–4 min per separation and ~3.5 min per song, three /// tracks of lead time keeps the next blend's stems ready even right after a skip. private static let upcomingStemWindow = 3 @@ -448,7 +464,7 @@ public final class Player { let dur = effectiveEndSeconds if isTransitioning { let plan = TransitionPlan( - curve: transitionSettings.curve, + curve: blendSettings.curve, duration: activeTransitionDurationSeconds ) // Drive the blend off the INCOMING deck's clock — it keeps advancing even after the @@ -460,7 +476,7 @@ public final class Player { let progress = plan.progress(position: incomingElapsed, startPosition: 0) transitionProgress = progress // Bass-swap: fade the incoming low end in so two basslines don't stack into mud. - if transitionSettings.bassSwapEnabled { + if blendSettings.bassSwapEnabled { applyBassSwap(progress: progress) } // When both decks have stems, shape the per-stem gains so the outgoing vocals duck out @@ -472,15 +488,18 @@ public final class Player { finishTransition() } } else if let next = nextIndex { + // Vote-adapted per pair: a downvoted blend gets simpler (shorter fade, fewer + // effects) the next time this pair comes around. Cached, so no fetch per tick. + let adapted = adaptedTransitionSettings(from: currentTrack, to: queue[next]).settings let plan = TransitionPlan( - curve: transitionSettings.curve, - duration: transitionSettings.durationSeconds + curve: adapted.curve, + duration: adapted.durationSeconds ) if plan.shouldStart(position: elapsed, trackDuration: dur, hasNextTrack: true) { beginTransition( toIndex: next, outgoingPosition: elapsed, - duration: transitionSettings.durationSeconds, + duration: adapted.durationSeconds, isUserInitiatedSkip: false ) } else if dur > 0 && elapsed >= dur - 0.05 {