Skip to content

Commit 5549e84

Browse files
Timeline tied to clock (#654)
* clock can update delta * tidy * fix missing arg * timeline tied to clock * usePlayheadScrub * better setting of clock on play * better loop logic and fixed duration * use clamped value for clock with goTo * usePlayheadScrub improvements Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 41cf1d6 commit 5549e84

11 files changed

Lines changed: 139 additions & 41 deletions

File tree

apps/clock-test-app/src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ function App() {
106106
clockRef.current?.sendTempoTap()
107107
}
108108

109+
const onDeltaSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
110+
clockRef.current!.beatDelta = Number(e.target.value)
111+
}
112+
109113
return (
110114
<>
111115
<section>
@@ -147,6 +151,9 @@ function App() {
147151
</form>
148152
</div>
149153
</section>
154+
<section className="grid">
155+
<input type="range" min={0} max={64} step={0.001} onChange={onDeltaSliderChange}></input>
156+
</section>
150157
<section className="grid">
151158
<div className="circle"></div>
152159
<code>

packages/clock/src/index.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,22 @@ export class Clock {
5656
this._beatDelta += deltaInc
5757
this._beatPulseComparisonDelta += deltaInc
5858

59-
const newBeatCount = Math.floor(this._beatDelta % 4)
60-
if (newBeatCount !== this._beatCount) {
61-
this._onNewBeat?.(newBeatCount)
62-
this._beatCount = newBeatCount
63-
}
59+
this.updateBeatCount()
6460

6561
requestAnimationFrame(this.tick)
6662

6763
this._lastTimestamp = timestamp
6864
}
6965
}
7066

67+
private updateBeatCount = () => {
68+
const newBeatCount = Math.floor(this._beatDelta % 4)
69+
if (newBeatCount !== this._beatCount) {
70+
this._onNewBeat?.(newBeatCount)
71+
this._beatCount = newBeatCount
72+
}
73+
}
74+
7175
/**
7276
* Sets the BPM
7377
* @param bpm Beats per minute.
@@ -103,6 +107,23 @@ export class Clock {
103107
return this._beatDelta
104108
}
105109

110+
/**
111+
* Sets the beat delta for external manual control (e.g. timelines)
112+
* @param value The new beat delta
113+
*/
114+
set beatDelta(value: number) {
115+
this._beatDelta = value
116+
this.updateBeatCount()
117+
}
118+
119+
/**
120+
* Sets the beat delta using a millisecond value, factoring in the current BPM
121+
* @param deltaMs The new beat delta in milliseconds
122+
*/
123+
set beatDeltaMs(deltaMs: number) {
124+
this.beatDelta = (deltaMs / MS_IN_MINUTE) * this._bpm
125+
}
126+
106127
/**
107128
* Gets the current beat count.
108129
* @returns The current beat count

packages/lfo-input/src/LFOInput.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ export class LFOInput implements IPlugin {
128128

129129
private inputLatches: Record<string, boolean> = {}
130130

131+
private lastClockBeatDelta: number = -1
132+
131133
private handleShot: ShotHandler = ({ delta, input, engine }) => {
132134
const val = Math.sin(delta)
133135

@@ -188,11 +190,13 @@ export class LFOInput implements IPlugin {
188190

189191
const tick = () => {
190192
requestAnimationFrame(() => {
191-
if (!clock.isRunning) {
193+
if (clock.beatDelta === this.lastClockBeatDelta) {
192194
tick()
193195
return
194196
}
195197

198+
this.lastClockBeatDelta = clock.beatDelta
199+
196200
const storeState = store.getState()
197201
handleEachInput<typeof this.optionNodesConfig>(
198202
storeState,

packages/timeline/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"vite-tsconfig-paths": "^5.1.4"
3737
},
3838
"dependencies": {
39+
"@hedron-gl/clock": "^1.0.0-alpha.4",
3940
"@hedron-gl/engine": "^1.0.0-alpha.4",
4041
"@hedron-gl/ui-core": "^1.0.0-alpha.4"
4142
},

packages/timeline/src/TimelineInput.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
isEqual,
66
OptionNodesFromConfigs,
77
} from '@hedron-gl/engine'
8-
import { DEFAULT_TIMELINE_ID } from './constants'
8+
import { DEFAULT_TIMELINE_ID, TIMELINE_DURATION } from './constants'
99
import { TimelineManager } from './TimelineManager'
1010
import { getTimelineTracks } from './selectors/getTimelineTracks'
1111
import { TimelineManagerKeyframeTrack } from './types'
@@ -56,10 +56,13 @@ export class TimelineInput implements IPlugin {
5656

5757
this.timelineManagers.set(
5858
DEFAULT_TIMELINE_ID,
59-
new TimelineManager({
60-
durationMs: 60000,
61-
tracks: [],
62-
}),
59+
new TimelineManager(
60+
{
61+
durationMs: TIMELINE_DURATION,
62+
tracks: [],
63+
},
64+
engine.clock,
65+
),
6366
)
6467

6568
this.timelineManagers.forEach((manager, timelineId) => {

packages/timeline/src/TimelineManager.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Clock } from '@hedron-gl/clock'
12
import type {
23
TimelineManagerData,
34
TimelineManagerTrack,
@@ -20,9 +21,11 @@ export class TimelineManager {
2021
private sortedKeyframesCache: Map<string, Keyframe[]> = new Map()
2122
private audioCache: Map<string, HTMLAudioElement> = new Map()
2223
private lastKeyframeIndex: Map<string, number> = new Map()
24+
private clock: Clock | null = null
2325

24-
constructor(timeline: TimelineManagerData) {
26+
constructor(timeline: TimelineManagerData, clock?: Clock) {
2527
this.timelineData = timeline
28+
this.clock = clock ?? null
2629
this.buildCache()
2730
}
2831

@@ -111,27 +114,30 @@ export class TimelineManager {
111114
if (this.lastFrameTime !== null) {
112115
const delta = now - this.lastFrameTime
113116
this.position = Math.min(this.position + delta, this.timelineData.durationMs)
117+
118+
if (this.clock) {
119+
this.clock.beatDeltaMs = this.position
120+
}
114121
}
115122
this.lastFrameTime = now
116123

117124
this.emitUpdate()
118125

119126
if (this.position >= this.timelineData.durationMs) {
120-
this.playing = false
121-
this.lastFrameTime = null
122-
this.resetKeyframeIndexes()
123-
return
127+
this.goTo(0)
124128
}
125129

126130
this.rafId = requestAnimationFrame(this.tick)
127131
}
128132

129133
play() {
130134
if (this.playing) return
131-
if (this.position >= this.timelineData.durationMs) {
132-
this.position = 0
133-
this.resetKeyframeIndexes()
135+
136+
if (this.clock) {
137+
this.clock.beatDeltaMs = this.position
138+
this.clock.continue()
134139
}
140+
135141
this.playing = true
136142
this.lastFrameTime = null
137143
this.rafId = requestAnimationFrame(this.tick)
@@ -146,6 +152,11 @@ export class TimelineManager {
146152
pause() {
147153
this.playing = false
148154
this.lastFrameTime = null
155+
156+
if (this.clock) {
157+
this.clock.stop()
158+
}
159+
149160
if (this.rafId !== null) {
150161
cancelAnimationFrame(this.rafId)
151162
this.rafId = null
@@ -163,6 +174,10 @@ export class TimelineManager {
163174
this.resetKeyframeIndexes()
164175
this.emitUpdate()
165176

177+
if (this.clock) {
178+
this.clock.beatDeltaMs = this.position
179+
}
180+
166181
// Seek audio tracks
167182
for (const { audio } of this.getAudioTracksWithAudio()) {
168183
audio.currentTime = this.position / 1000

packages/timeline/src/components/Timeline/Timeline.tsx

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react'
1+
import { useEffect, useRef, useState } from 'react'
22
import c from './Timeline.module.css'
33
import '@hedron-gl/ui-core/base.css'
44
import '@hedron-gl/ui-core/fonts.css'
55
import { TrackKeyframes } from './TrackKeyframes'
6+
import { usePlayheadScrub } from './usePlayheadScrub'
67
import { TimelineManagerTrack } from '@/types'
78

89
export interface TimelineProps {
@@ -29,7 +30,7 @@ export function Timeline({
2930
onKeyframeInsert,
3031
}: TimelineProps) {
3132
const { durationMs, tracks } = timeline
32-
const trackAreaRef = useRef<HTMLDivElement>(null)
33+
const rulerAreaRef = useRef<HTMLDivElement>(null)
3334
const [selectedTrack, setSelectedTrack] = useState<string | null>(null)
3435
const [selectedKeyframe, setSelectedKeyframe] = useState<string | null>(null)
3536

@@ -47,21 +48,7 @@ export function Timeline({
4748
return () => window.removeEventListener('keydown', handleKeyDown)
4849
}, [selectedKeyframe, onKeyframeDelete, selectedTrack, playheadPositionMs, onKeyframeInsert])
4950

50-
const handleTrackClick = useCallback(
51-
(e: React.MouseEvent<HTMLDivElement>) => {
52-
if (!trackAreaRef.current || !onPlayheadChange) return
53-
const rect = trackAreaRef.current.getBoundingClientRect()
54-
const headerWidth = parseFloat(
55-
getComputedStyle(trackAreaRef.current).getPropertyValue('--trackHeaderWidth'),
56-
)
57-
const trackWidth = rect.width - headerWidth
58-
const x = e.clientX - rect.left - headerWidth
59-
if (x < 0) return
60-
const time = (x / trackWidth) * durationMs
61-
onPlayheadChange(Math.max(0, Math.min(durationMs, time)))
62-
},
63-
[durationMs, onPlayheadChange],
64-
)
51+
usePlayheadScrub(durationMs, rulerAreaRef, playheadPositionMs, onPlayheadChange)
6552

6653
const playheadPercent = (playheadPositionMs / durationMs) * 100
6754

@@ -85,8 +72,10 @@ export function Timeline({
8572
{(playheadPositionMs / 1000).toFixed(1)}s / {durationSec}s
8673
</span>
8774
</div>
88-
<div className={c.body} ref={trackAreaRef} onClick={handleTrackClick}>
89-
<div className={c.ruler}>{rulerMarks}</div>
75+
<div className={c.body}>
76+
<div className={c.ruler} ref={rulerAreaRef}>
77+
{rulerMarks}
78+
</div>
9079
{tracks.map((track) => {
9180
const isSelected = selectedTrack === track.id
9281
return (
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useElementScrub } from '@hedron-gl/ui-core'
2+
import { useCallback, useEffect, useRef } from 'react'
3+
4+
export const usePlayheadScrub = (
5+
durationMs: number,
6+
playheadAreaRef: React.RefObject<HTMLDivElement>,
7+
playheadPositionMs: number,
8+
onPlayheadChange?: (time: number) => void,
9+
) => {
10+
// We need this as a ref for useElementScrub to work properly
11+
const playheadPositionRef = useRef(0)
12+
13+
useEffect(() => {
14+
playheadPositionRef.current = playheadPositionMs
15+
}, [playheadPositionMs])
16+
17+
const clampTime = useCallback(
18+
(time: number) => Math.max(0, Math.min(durationMs, time)),
19+
[durationMs],
20+
)
21+
22+
const onPlayheadAreaScrub = useCallback(
23+
({ x }: { x: number }) => {
24+
const newTime = clampTime(playheadPositionRef.current + x * durationMs)
25+
26+
onPlayheadChange?.(newTime)
27+
playheadPositionRef.current = newTime
28+
},
29+
[clampTime, durationMs, onPlayheadChange],
30+
)
31+
32+
const onRulerMouseDown = useCallback(
33+
(e: MouseEvent) => {
34+
if (!playheadAreaRef.current || !onPlayheadChange) return
35+
const rect = playheadAreaRef.current.getBoundingClientRect()
36+
if (rect.width <= 0) return
37+
38+
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
39+
const time = clampTime((x / rect.width) * durationMs)
40+
onPlayheadChange(time)
41+
playheadPositionRef.current = time
42+
},
43+
[clampTime, durationMs, onPlayheadChange, playheadAreaRef],
44+
)
45+
46+
useEffect(() => {
47+
const current = playheadAreaRef.current
48+
current?.addEventListener('mousedown', onRulerMouseDown)
49+
return () => {
50+
current?.removeEventListener('mousedown', onRulerMouseDown)
51+
}
52+
}, [onRulerMouseDown, playheadAreaRef])
53+
54+
useElementScrub(playheadAreaRef, onPlayheadAreaScrub, 'ew-resize')
55+
}

packages/timeline/src/components/TimelineGlobalPanel/useTimelineData.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ import { useMemo } from 'react'
22

33
import { useEngineStoreDeepEqual } from '@hedron-gl/ui-core'
44

5-
import { DEFAULT_TIMELINE_ID } from '@/constants'
5+
import { DEFAULT_TIMELINE_ID, TIMELINE_DURATION } from '@/constants'
66
import { getTimelineTracks } from '@/selectors/getTimelineTracks'
77

8-
const TIMELINE_DURATION = 10000
9-
108
export const useTimelineData = () => {
119
const tracks = useEngineStoreDeepEqual((state) => getTimelineTracks(state, DEFAULT_TIMELINE_ID))
1210

packages/timeline/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export const DEFAULT_TIMELINE_ID = 'default-timeline'
22

33
export const DEFAULT_AUDIO_TRACK_ID = 'default-audio-track'
4+
5+
export const TIMELINE_DURATION = 10000

0 commit comments

Comments
 (0)