feat: add marketing video mode and Remotion marketing video project

Add debug-only Marketing Video Mode toggle that enables hands-free
screen recording across the app: auto-scrolling Featured Trips carousel,
auto-filling trip wizard, smooth trip detail scrolling via CADisplayLink,
and trip options auto-sort with scroll.

Add Remotion marketing video project with 6 scene compositions using
image sequences extracted from screen recordings, varied phone entrance
animations, and deduped frames for smooth playback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-13 12:07:35 -06:00
parent 67965cbac6
commit 5f5b137e64
655 changed files with 4008 additions and 63 deletions

View File

@@ -28,6 +28,7 @@ struct TripWizardView: View {
var body: some View {
NavigationStack {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView(.vertical) {
VStack(spacing: Theme.Spacing.lg) {
// Step 1: Planning Mode (always visible)
@@ -130,11 +131,23 @@ struct TripWizardView: View {
}
.transition(.opacity)
}
Color.clear
.frame(height: 1)
.id("wizardBottom")
}
.padding(Theme.Spacing.md)
.frame(width: geometry.size.width)
.animation(Theme.Animation.prefersReducedMotion ? .none : .easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
}
#if DEBUG
.onChange(of: viewModel.planningMode) { _, newMode in
if newMode == .gameFirst && UserDefaults.standard.bool(forKey: "marketingVideoMode") {
marketingAutoFill(proxy: proxy)
}
}
#endif
}
}
.themedBackground()
.navigationTitle("Plan a Trip")
@@ -335,6 +348,72 @@ struct TripWizardView: View {
}
return cities.joined(separator: "")
}
// MARK: - Marketing Video Auto-Fill
#if DEBUG
private func marketingAutoFill(proxy: ScrollViewProxy) {
// Pre-fetch data off the main thread, then run a clean sequential fill
Task {
let astros = AppDataProvider.shared.teams.first { $0.fullName.contains("Astros") }
let allGames = try? await AppDataProvider.shared.allGames(for: [.mlb])
let astrosGames = (allGames ?? []).filter {
$0.homeTeamId == astros?.id || $0.awayTeamId == astros?.id
}
let pickedGames = Array(astrosGames.shuffled().prefix(3))
let pickedIds = Set(pickedGames.map { $0.id })
// Sequential fill with generous pauses no competing animations
await MainActor.run {
// Step 1: Select MLB
viewModel.gamePickerSports = [.mlb]
}
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run {
// Step 2: Select Astros
if let astros {
viewModel.gamePickerTeamIds = [astros.id]
}
}
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run {
// Step 3: Pick games + set date range
if !pickedIds.isEmpty {
viewModel.selectedGameIds = pickedIds
if let earliest = pickedGames.map({ $0.dateTime }).min(),
let latest = pickedGames.map({ $0.dateTime }).max() {
viewModel.startDate = Calendar.current.date(byAdding: .day, value: -1, to: earliest) ?? earliest
viewModel.endDate = Calendar.current.date(byAdding: .day, value: 1, to: latest) ?? latest
}
}
}
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run {
// Step 4: Balanced route
viewModel.routePreference = .balanced
viewModel.hasSetRoutePreference = true
}
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run {
// Step 5: Allow repeat cities
viewModel.allowRepeatCities = true
viewModel.hasSetRepeatCities = true
}
try? await Task.sleep(for: .seconds(0.5))
// Single smooth scroll to bottom after everything is laid out
await MainActor.run {
withAnimation(.easeInOut(duration: 5.0)) {
proxy.scrollTo("wizardBottom", anchor: .bottom)
}
}
}
}
#endif
}
// MARK: - Preview