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
2 changes: 1 addition & 1 deletion App/Continuity/Views/ContinuityApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions App/Continuity/Views/NowPlayingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -93,6 +99,8 @@ struct NowPlayingView: View {

transitionSection

TransitionVoteBar()

scrubber
transport

Expand Down
9 changes: 9 additions & 0 deletions App/Continuity/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransitionVote>(
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
Expand Down
94 changes: 94 additions & 0 deletions App/Continuity/Views/TransitionVoteBar.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 24 additions & 0 deletions Packages/ContinuityKit/Sources/Domain/TransitionSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
53 changes: 53 additions & 0 deletions Packages/ContinuityKit/Sources/Domain/TransitionVote.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading