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
34 changes: 26 additions & 8 deletions src/editor-shell/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { StoryboardFilmstrip } from './StoryboardFilmstrip'
import { SlideSettingsDialog } from './SlideSettingsDialog'
import { TitleSlideDialog } from './TitleSlideDialog'
import { useProject } from './useProject'
import { useBeatGrid } from './useBeatGrid'

export function App() {
const playerRef = useRef<PlayerRef>(null)
Expand All @@ -37,13 +38,16 @@ export function App() {
aspectRatio,
setAspectRatio,
audioTracks,
beatGridCache,
globalSettings,
setGlobalSettings,
manualBeatGrid,
slides,
setSlides,
soundtrackFilename,
themeName,
setThemeName,
updateBeatGridPersist,
loading,
error,
corruptError,
Expand All @@ -52,6 +56,13 @@ export function App() {
recentProjects,
} = project

const beatGrid = useBeatGrid({
audioTracks,
onPersistChange: updateBeatGridPersist,
persisted: { beatGridCache, manualBeatGrid },
soundtrackFilename,
})

const handleReorder = useCallback((fromIndex: number, toIndex: number) => {
setSlides(prev => moveSlide(prev, fromIndex, toIndex))
}, [setSlides])
Expand Down Expand Up @@ -106,8 +117,9 @@ export function App() {
durationInFrames: selectedSoundtrack.durationInFrames,
}
: undefined,
beatGrid.effectiveBeatGrid,
),
[globalSettings, selectedSoundtrack, slides],
[beatGrid.effectiveBeatGrid, globalSettings, selectedSoundtrack, slides],
)
const totalFrames = renderPlan.totalFrames > 0 ? renderPlan.totalFrames : FPS
const canvas = dimensionsForAspectRatio(aspectRatio)
Expand Down Expand Up @@ -195,18 +207,24 @@ export function App() {
) : null}
sidebar={
<EditorSidebar
analysisStatus={beatGrid.analysisStatus}
aspectRatio={aspectRatio}
audioTracks={audioTracks}
effectiveBeatGrid={beatGrid.effectiveBeatGrid}
jamendoClientId={import.meta.env.VITE_JAMENDO_CLIENT_ID}
manualBeatGrid={manualBeatGrid}
onAddTitleSlide={handleAddTitleSlide}
onApplyManualBpm={beatGrid.applyManualBpm}
onApplyTapTimestamps={beatGrid.applyTapTimestamps}
onAspectRatioChange={setAspectRatio}
settings={globalSettings}
themeName={themeName}
onClearManualBeatGrid={beatGrid.clearManualBeatGrid}
onJamendoAdd={project.addJamendoTrack}
onSettingsChange={handleSettingsChange}
onSoundtrackChange={project.changeSoundtrack}
onThemeChange={handleThemeChange}
audioTracks={audioTracks}
settings={globalSettings}
soundtrackFilename={soundtrackFilename}
onSoundtrackChange={project.changeSoundtrack}
jamendoClientId={import.meta.env.VITE_JAMENDO_CLIENT_ID}
onJamendoAdd={project.addJamendoTrack}
onAddTitleSlide={handleAddTitleSlide}
themeName={themeName}
/>
}
/>
Expand Down
195 changes: 195 additions & 0 deletions src/editor-shell/BeatGridPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { BeatGrid } from '../beat-grid/types'
import type { BeatGridAnalysisStatus } from './useBeatGrid'

const MIN_TAP_COUNT = 8

type ManualBpmFieldsProps = {
defaultBpm?: number
defaultOffsetSecs?: number
manualBeatGrid: BeatGrid | undefined
onApplyManualBpm: (bpm: number, firstBeatOffsetSecs: number) => void
onClearManualBeatGrid: () => void
}

function ManualBpmFields({
defaultBpm,
defaultOffsetSecs = 0,
manualBeatGrid,
onApplyManualBpm,
onClearManualBeatGrid,
}: ManualBpmFieldsProps) {
const [bpmInput, setBpmInput] = useState(defaultBpm ? String(Math.round(defaultBpm)) : '')
const [offsetInput, setOffsetInput] = useState(String(Number(defaultOffsetSecs.toFixed(2))))

function handleApplyManualBpm() {
const bpm = parseFloat(bpmInput)
const offset = parseFloat(offsetInput)
if (Number.isNaN(bpm) || bpm <= 0) return
if (Number.isNaN(offset) || offset < 0) return
onApplyManualBpm(bpm, offset)
}

return (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Manual BPM</Label>
<div className="flex items-center gap-2">
<Input
aria-label="BPM"
className="h-7 w-16 text-right"
inputMode="decimal"
min={1}
onChange={(event) => setBpmInput(event.target.value)}
placeholder="120"
type="number"
value={bpmInput}
/>
<Input
aria-label="First beat offset in seconds"
className="h-7 w-16 text-right"
inputMode="decimal"
min={0}
onChange={(event) => setOffsetInput(event.target.value)}
placeholder="0"
step={0.01}
type="number"
value={offsetInput}
/>
<span className="text-xs text-muted-foreground">s offset</span>
</div>
<div className="flex gap-2">
<Button className="flex-1" onClick={handleApplyManualBpm} size="sm" variant="outline">
Apply
</Button>
{manualBeatGrid ? (
<Button onClick={onClearManualBeatGrid} size="sm" variant="outline">
Use detected
</Button>
) : null}
</div>
</div>
)
}

type Props = {
analysisStatus: BeatGridAnalysisStatus
beatSync: boolean
effectiveBeatGrid: BeatGrid | undefined
manualBeatGrid: BeatGrid | undefined
onApplyManualBpm: (bpm: number, firstBeatOffsetSecs: number) => void
onApplyTapTimestamps: (tapTimestampsMs: number[]) => void
onClearManualBeatGrid: () => void
soundtrackBlobUrl: string
}

function formatStatus(
analysisStatus: BeatGridAnalysisStatus,
beatSync: boolean,
effectiveBeatGrid: BeatGrid | undefined,
manualBeatGrid: BeatGrid | undefined,
): string {
if (analysisStatus === 'analyzing') return 'Analyzing soundtrack…'
if (analysisStatus === 'error') return 'Could not detect tempo'
if (!effectiveBeatGrid) return 'No tempo detected yet'
const bpm = `${Math.round(effectiveBeatGrid.bpm)} BPM`
const source = manualBeatGrid ? 'manual' : 'detected'
const sync = beatSync ? 'syncing to beat' : 'beat sync off'
return `${bpm} · ${source} · ${sync}`
}

export function BeatGridPanel({
analysisStatus,
beatSync,
effectiveBeatGrid,
manualBeatGrid,
onApplyManualBpm,
onApplyTapTimestamps,
onClearManualBeatGrid,
soundtrackBlobUrl,
}: Props) {
const audioRef = useRef<HTMLAudioElement | null>(null)
const [tapCount, setTapCount] = useState(0)
const [tapping, setTapping] = useState(false)
const tapTimestampsRef = useRef<number[]>([])

useEffect(() => () => {
audioRef.current?.pause()
}, [])

const stopTapping = useCallback(() => {
setTapping(false)
audioRef.current?.pause()
tapTimestampsRef.current = []
setTapCount(0)
}, [])

const handleStartTapping = useCallback(async () => {
const audio = audioRef.current
if (!audio) return
tapTimestampsRef.current = []
setTapCount(0)
setTapping(true)
audio.currentTime = 0
try {
await audio.play()
} catch {
setTapping(false)
}
}, [])

const handleTap = useCallback(() => {
const audio = audioRef.current
if (!audio || !tapping) return
const timestamps = [...tapTimestampsRef.current, audio.currentTime * 1000]
tapTimestampsRef.current = timestamps
setTapCount(timestamps.length)
if (timestamps.length >= MIN_TAP_COUNT) {
stopTapping()
onApplyTapTimestamps(timestamps)
}
}, [onApplyTapTimestamps, stopTapping, tapping])

const manualDefaultsKey = manualBeatGrid
? `manual-${manualBeatGrid.bpm}-${manualBeatGrid.firstBeatOffsetSecs}`
: `detected-${effectiveBeatGrid?.bpm ?? 'none'}-${effectiveBeatGrid?.firstBeatOffsetSecs ?? 0}`

return (
<div className="flex flex-col gap-3 border-t pt-3">
<p className="text-xs text-muted-foreground">
{formatStatus(analysisStatus, beatSync, effectiveBeatGrid, manualBeatGrid)}
</p>

<audio preload="auto" ref={audioRef} src={soundtrackBlobUrl} />

<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Tap tempo</Label>
<div className="flex gap-2">
{tapping ? (
<>
<Button className="flex-1" onClick={handleTap} size="sm">
Tap ({tapCount}/{MIN_TAP_COUNT})
</Button>
<Button onClick={stopTapping} size="sm" variant="outline">Cancel</Button>
</>
) : (
<Button className="w-full" onClick={handleStartTapping} size="sm" variant="outline">
Tap along to the beat
</Button>
)}
</div>
</div>

<ManualBpmFields
defaultBpm={manualBeatGrid?.bpm ?? effectiveBeatGrid?.bpm}
defaultOffsetSecs={manualBeatGrid?.firstBeatOffsetSecs ?? effectiveBeatGrid?.firstBeatOffsetSecs}
key={manualDefaultsKey}
manualBeatGrid={manualBeatGrid}
onApplyManualBpm={onApplyManualBpm}
onClearManualBeatGrid={onClearManualBeatGrid}
/>
</div>
)
}
Loading
Loading