@@ -12,6 +12,33 @@ function useVideoPlayer(videoRef: Ref<HTMLVideoElement | null>, rangeRef: WatchS
1212 const duration = ref <number | null >(null )
1313 /** Current playback time in ms */
1414 const currentTime = ref (0 )
15+ /** Version async play requests so pause/stop can invalidate stale play() continuations after await. */
16+ let playRequestVersion = 0
17+ /** Latest requested preview seek time in ms while a browser seek may still be in flight. */
18+ let pendingSeekTime: number | null = null
19+
20+ function flushPendingSeek() {
21+ const video = videoRef .value
22+ if (video == null || pendingSeekTime == null || video .seeking ) return
23+ const nextTime = pendingSeekTime
24+ pendingSeekTime = null
25+ video .currentTime = nextTime / 1000
26+ }
27+
28+ function seek(timeInMs : number ) {
29+ const nextTime = Math .max (0 , timeInMs )
30+ currentTime .value = nextTime
31+ pendingSeekTime = nextTime
32+ flushPendingSeek ()
33+ }
34+
35+ function pausePlayback() {
36+ playRequestVersion += 1
37+ stopTick ()
38+ const video = videoRef .value
39+ if (video == null ) return
40+ video .pause ()
41+ }
1542
1643 const [startTick, stopTick] = useTick (() => {
1744 const video = videoRef .value
@@ -29,12 +56,13 @@ function useVideoPlayer(videoRef: Ref<HTMLVideoElement | null>, rangeRef: WatchS
2956 currentTime .value = video .currentTime * 1000
3057 })
3158
32- // If range changes, replay from range start
59+ // Keep preview time inside the current play range when the range changes.
3360 watch (
3461 rangeRef ,
3562 (newRange ) => {
3663 if (newRange == null || videoRef .value == null ) return
37- videoRef .value .currentTime = newRange .start / 1000
64+ if (currentTime .value >= newRange .start && currentTime .value < newRange .end ) return
65+ seek (newRange .start )
3866 },
3967 { immediate: true }
4068 )
@@ -50,31 +78,36 @@ function useVideoPlayer(videoRef: Ref<HTMLVideoElement | null>, rangeRef: WatchS
5078 (video , _ , onCleanup ) => {
5179 if (video == null ) return
5280 video .addEventListener (' loadedmetadata' , handleLoadedMetadata )
53- onCleanup (() => video .removeEventListener (' loadedmetadata' , handleLoadedMetadata ))
81+ video .addEventListener (' seeked' , flushPendingSeek )
82+ onCleanup (() => {
83+ video .removeEventListener (' loadedmetadata' , handleLoadedMetadata )
84+ video .removeEventListener (' seeked' , flushPendingSeek )
85+ })
5486 },
5587 { immediate: true }
5688 )
5789
5890 async function play() {
5991 if (isPlaying .value ) return
6092 isPlaying .value = true
93+ const requestVersion = ++ playRequestVersion
6194 startTick ()
6295 const video = await untilNotNull (videoRef )
96+ if (! isPlaying .value || requestVersion !== playRequestVersion ) return
6397 return video .play ()
6498 }
6599
66- async function stop() {
100+ function stop() {
67101 if (! isPlaying .value ) return
68102 isPlaying .value = false
69- stopTick ()
70- const video = await untilNotNull (videoRef )
71- video .pause ()
103+ pausePlayback ()
72104 }
73105
74106 return {
75107 isPlaying ,
76108 duration ,
77109 currentTime ,
110+ seek ,
78111 play ,
79112 stop
80113 }
@@ -147,7 +180,7 @@ const playingRange = computed<PlayRange | null>(() => {
147180 }
148181})
149182
150- const { isPlaying, duration : videoDurationRef, currentTime, play, stop } = useVideoPlayer (videoRef , playingRange )
183+ const { isPlaying, duration : videoDurationRef, currentTime, seek, play, stop } = useVideoPlayer (videoRef , playingRange )
151184
152185/** Playback progress in range [0, 1] */
153186const progress = computed (() => {
@@ -175,7 +208,7 @@ watch(
175208 { immediate: true }
176209)
177210
178- const trackRef = ref <HTMLDivElement | null >(null )
211+ const trackInnerRef = ref <HTMLDivElement | null >(null )
179212// Shows nudge animation on mount, hides after first user hover
180213const shouldNudge = ref (true )
181214
@@ -196,11 +229,12 @@ const currentTimeStyle = computed(() => {
196229 return { left }
197230})
198231
199- type DragTarget = ' start' | ' end'
232+ type DragTarget = ' start' | ' end' | ' preview '
200233type Dragging = {
201234 target: DragTarget
202235 pointerId: number
203236 rect: DOMRect
237+ wasPlaying: boolean
204238}
205239
206240// TODO: Check if we can reuse `useDraggable` from utils
@@ -216,44 +250,100 @@ function stopDragging() {
216250
217251onScopeDispose (stopDragging )
218252
253+ function getTimeFromPointer(clientX : number , rect : DOMRect , duration : number ) {
254+ if (rect .width <= 0 ) return 0
255+ const ratio = (clientX - rect .left ) / rect .width
256+ return clamp (ratio * duration , 0 , duration )
257+ }
258+
259+ function getPreviewTimeForDragTarget(target : Exclude <DragTarget , ' preview' >) {
260+ if (target === ' start' ) return cutStartRef .value
261+ // end - 1ms: keep the right-handle preview just inside the play range so it doesn't immediately wrap to start.
262+ return Math .max (cutStartRef .value , cutEndRef .value - 1 )
263+ }
264+
219265function handleDragStart(target : DragTarget , e : PointerEvent ) {
220- const track = trackRef .value
221- if (track == null ) return
222- const rect = track .getBoundingClientRect ()
266+ // Ignore extra pointerdowns (for example from multi-touch) until the current drag finishes.
267+ if (dragging != null ) return
268+ const rect = trackInnerRef .value ?.getBoundingClientRect () ?? null
269+ if (rect == null ) return
270+ const videoDuration = videoDurationRef .value
271+ if (videoDuration == null ) return
272+ shouldNudge .value = false
273+ const wasPlaying = isPlaying .value
274+ stop ()
223275 dragging = {
224276 target ,
225277 pointerId: e .pointerId ,
226- rect
278+ rect ,
279+ wasPlaying
280+ }
281+ if (target === ' preview' ) {
282+ const time = getTimeFromPointer (e .clientX , dragging .rect , videoDuration )
283+ seek (clamp (time , cutStartRef .value , cutEndRef .value ))
284+ } else {
285+ seek (getPreviewTimeForDragTarget (target ))
227286 }
228287 e .preventDefault ()
229288 window .addEventListener (' pointermove' , handleDragMove )
230289 window .addEventListener (' pointerup' , handleDragEnd )
231290 window .addEventListener (' pointercancel' , handleDragEnd )
232291}
233292
293+ function handleSegmentPointerDown(e : PointerEvent ) {
294+ // Ignore pointerdown events from child handles (segment markers).
295+ if (e .target !== e .currentTarget ) return
296+ handleDragStart (' preview' , e )
297+ }
298+
234299function handleDragMove(e : PointerEvent ) {
235300 if (dragging == null || e .pointerId !== dragging .pointerId ) return
236301 const videoDuration = videoDurationRef .value
237302 if (videoDuration == null ) return
238- const ratio = (e .clientX - dragging .rect .left ) / dragging .rect .width
239- const time = snap (ratio * videoDuration )
240- const minDuration = precision
303+ const time = getTimeFromPointer (e .clientX , dragging .rect , videoDuration )
304+ if (dragging .target === ' preview' ) {
305+ seek (clamp (time , cutStartRef .value , cutEndRef .value ))
306+ return
307+ }
241308 if (dragging .target === ' start' ) {
242- cutStartRef .value = clamp (time , 0 , cutEndRef . value - minDuration )
309+ cutStartRef .value = adjustStartTime (time )
243310 } else {
244- cutEndRef .value = clamp (time , cutStartRef . value + minDuration , videoDuration )
311+ cutEndRef .value = adjustEndTime (time , videoDuration )
245312 }
313+ seek (getPreviewTimeForDragTarget (dragging .target ))
246314}
247315
248316function handleDragEnd(e : PointerEvent ) {
249317 if (dragging == null || e .pointerId !== dragging .pointerId ) return
318+ const { target, wasPlaying } = dragging
250319 stopDragging ()
251- notifyFramesConfigChanged ()
252- play ()
320+ if (target !== ' preview' ) {
321+ if (target === ' start' ) {
322+ cutStartRef .value = adjustStartTime (cutStartRef .value , true )
323+ } else {
324+ cutEndRef .value = adjustEndTime (cutEndRef .value , videoDurationRef .value ! , true )
325+ }
326+ seek (getPreviewTimeForDragTarget (target ))
327+ notifyFramesConfigChanged ()
328+ }
329+ if (wasPlaying ) play ()
330+ }
331+
332+ function adjustStartTime(newTime : number , withSnap = false ) {
333+ const time = withSnap ? snap (newTime ) : newTime
334+ return clamp (time , 0 , cutEndRef .value - minDuration )
335+ }
336+
337+ function adjustEndTime(newTime : number , videoDuration : number , withSnap = false ) {
338+ const time = withSnap ? snap (newTime ) : newTime
339+ return clamp (time , cutStartRef .value + minDuration , videoDuration )
253340}
254341
255342/** Precision for snapping in ms */
256343const precision = 100
344+ /** Minimum allowed segment duration in ms */
345+ const minDuration = precision
346+
257347function snap(timeInMs : number ) {
258348 return Math .round (timeInMs / precision ) * precision
259349}
@@ -288,9 +378,14 @@ function formatTime(timeInMs: number) {
288378 @stop =" stop "
289379 />
290380 <div class =" timeline" >
291- <div ref =" trackRef" class =" track" >
292- <div class =" track-inner" :class =" { nudge: shouldNudge }" @mouseenter.once =" shouldNudge = false" >
293- <div class =" segment" :style =" segmentStyle" >
381+ <div class =" track" >
382+ <div
383+ ref =" trackInnerRef"
384+ class =" track-inner"
385+ :class =" { nudge: shouldNudge }"
386+ @mouseenter.once =" shouldNudge = false"
387+ >
388+ <div class =" segment" :style =" segmentStyle" @pointerdown =" handleSegmentPointerDown" >
294389 <button
295390 v-radar =" { name: 'Start marker', desc: 'Drag to adjust start time of extracted segment' }"
296391 class =" segment-marker left"
@@ -310,9 +405,10 @@ function formatTime(timeInMs: number) {
310405 </div >
311406 </div >
312407 </div >
313- <div class =" time-row" >
314- <span class =" time" >{{ formatTime(0) }}</span >
315- <span class =" time" >{{ formatTime(videoDurationRef) }}</span >
408+ <div class =" flex items-center text-xs" >
409+ <span class =" w-9 text-grey-700" >{{ formatTime(currentTime) }}</span >
410+ <span class =" mr-1 text-grey-600" >/</span >
411+ <span class =" w-9 text-grey-600" >{{ formatTime(videoDurationRef) }}</span >
316412 </div >
317413 </div >
318414 </div >
@@ -398,9 +494,12 @@ function formatTime(timeInMs: number) {
398494 position : relative ;
399495 width : 100% ;
400496 height : 20px ;
401- padding : 0 5px ;
402497 background : var (--ui-color-grey-400 );
403498 border-radius : 2px ;
499+ --segment-marker-width : 10px ;
500+ --playhead-width : 2px ;
501+ --track-padding : calc (var (--segment-marker-width ) + var (--playhead-width ) / 2 );
502+ padding : 0 var (--track-padding );
404503}
405504
406505.track-inner {
@@ -451,7 +550,7 @@ function formatTime(timeInMs: number) {
451550
452551.segment {
453552 position : absolute ;
454- margin : 0 -5 px ;
553+ margin : 0 calc ( -1 * var ( --track-padding )) ;
455554 top : 0 ;
456555 bottom : 0 ;
457556 background : var (--ui-color-primary-200 );
@@ -460,13 +559,12 @@ function formatTime(timeInMs: number) {
460559 align-items : center ;
461560 justify-content : space-between ;
462561 padding : 0 ;
463- transition :
464- left 0.1s ,
465- right 0.1s ;
562+ cursor : pointer ;
563+ touch-action : none ;
466564}
467565
468566.segment-marker {
469- width : 10 px ;
567+ width : var ( --segment-marker-width ) ;
470568 height : 20px ;
471569 background : var (--ui-color-primary-500 );
472570 border-radius : 2px ;
@@ -502,22 +600,19 @@ function formatTime(timeInMs: number) {
502600 position : absolute ;
503601 top : 0 ;
504602 bottom : 0 ;
505- width : 2 px ;
603+ width : 0 ;
506604 pointer-events : none ;
507- transform : translateX (-1px );
508- background : var (--ui-color-primary-500 );
509605}
510606
511- .time-row {
512- display : flex ;
513- align-items : center ;
514- justify-content : space-between ;
515- font-size : 12px ;
516- line-height : 18px ;
517- color : var (--ui-color-grey-700 );
518- }
519-
520- .time {
521- white-space : nowrap ;
607+ .current-time ::before {
608+ content : ' ' ;
609+ position : absolute ;
610+ top : 0 ;
611+ bottom : 0 ;
612+ left : calc (-1 * var (--playhead-width ) / 2 );
613+ width : var (--playhead-width );
614+ border-radius : 1px ;
615+ background : var (--ui-color-yellow-500 );
616+ box-shadow : 0 0 0 1px rgba (255 , 255 , 255 , 0.35 );
522617}
523618 </style >
0 commit comments