import mousetrap from 'mousetrap'; import { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react'; import { animated, useSpring } from 'react-spring'; import { useDrag } from 'react-use-gesture'; import create from 'zustand'; import { PressureIcon, SlideKeyframe } from '../components/icons'; import PlaySkipIconAni from '../components/play-skip'; import Selection from '../components/selection'; import { anySlide, loopBeginSlide, loopSlide, slide, slideTypes, toolToSlide } from '../timeline'; import { TimedVideoPlayer } from './present'; import AppBar from '@material-ui/core/AppBar'; import Button from '@material-ui/core/Button'; import Fab from '@material-ui/core/Fab'; import Slider from '@material-ui/core/Slider'; import Switch from '@material-ui/core/Switch'; import Toolbar from '@material-ui/core/Toolbar'; import ZoomInRoundedIcon from '@material-ui/icons/ZoomInRounded'; import ZoomOutRoundedIcon from '@material-ui/icons/ZoomOutRounded'; import ToggleButton from '@material-ui/lab/ToggleButton'; import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'; import Icon from '@mdi/react'; import FullscreenRoundedIcon from '@material-ui/icons/FullscreenRounded'; import NavigateBeforeRoundedIcon from '@material-ui/icons/NavigateBeforeRounded'; import NavigateNextRoundedIcon from '@material-ui/icons/NavigateNextRounded'; import SkipPreviousRoundedIcon from '@material-ui/icons/SkipPreviousRounded'; import { mdiCursorDefault } from '@mdi/js'; import CodeRoundedIcon from '@material-ui/icons/CodeRounded'; import GetAppRoundedIcon from '@material-ui/icons/GetAppRounded'; import VideoLabelRoundedIcon from '@material-ui/icons/VideoLabelRounded'; var keyframeInAnimations: { [key: string]: { x: number; y: number; }; } = {}; var slideAPIs: { [key: string]: any; }[] = []; var player = new TimedVideoPlayer(); var useWorkingTimeline = create((set, get) => ({ timeline: [], setTimeline: (newTimeline: anySlide[]) => set(() => ({ timeline: newTimeline })), refreshLiveTimeline: () => { player.timeline.slides = Array(...((get() as any).timeline)); player.timeline.slides = player.timeline.slides.filter(slide => slide != null); player.timeline.slides.sort((a, b) => a.frame - b.frame); player.timeline.slides[-1] = { // TODO: dry id: '00000000-0000-0000-0000-000000000000', frame: 0, type: 'default', clickThroughBehaviour: 'ImmediatelySkip', }; }, })); var getTimelineZoom = create(set => ({ zoom: 0.687077725615, setZoom: (newValue: number) => set(() => ({ zoom: newValue })), })); var zoomToPx = (zoom: number) => (12 - 0.5) * zoom ** (1 / 0.4) + 0.5; function getFrameAtOffset(offset: number, timelineZoom: number) { var timeline = document.querySelector('.timeline .timelineInner'); var currentOffset = timeline.scrollLeft; var frame = (offset + currentOffset) / zoomToPx(timelineZoom); return frame; } var useTimelineLabels = create(set => ({ labels: [], setLabels: (newLabels: Array) => set(() => ({ labels: newLabels })), })); var useFrame = create(set => ({ currentFrame: 0, setFrame: (newFrame: number) => set(() => ({ currentFrame: newFrame })), })); var usePlaying = create(set => ({ playing: false, setPlaying: (playing: boolean) => set(() => ({ playing })), })); function calculateSelectionOffsets(left: slideTypes, right: slideTypes) { var offsets = { default: { left: -6, right: 6 }, loop: { left: -3, right: 1 }, loopBegin: { left: -1, right: 3 }, delay: { left: -6, right: 6 }, speedChange: { left: -6, right: 6 }, }; var offsetLeft = offsets[left].left; var offsetWidth = offsets[right].right - offsetLeft; return [offsetLeft, offsetWidth]; } function TimelineKeyframe(props: { slide: slide; }) { var workingTimeline = useWorkingTimeline((st: any) => st.timeline); var setWorkingTimeline = useWorkingTimeline((st: any) => st.setTimeline); var updateTimeline = useWorkingTimeline((st: any) => st.refreshLiveTimeline); function modifySlide(newProps: Partial) { var slide = workingTimeline.find((s: anySlide) => s.id == props.slide.id); slide = Object.assign(slide, newProps); setWorkingTimeline(workingTimeline); } var dragRef = useRef(null); var loopStartRef = useRef(null); var loopEndRef = useRef(null); var [firstRender, setFirstRender] = useState(true); var [spring, api] = useSpring(() => ({ frame: props.slide.frame, begin: (props.slide as loopSlide).beginFrame || 0, y: 44, config: { mass: 0.5, tension: 500, friction: 20 }, })); slideAPIs[props.slide.id] = api; useEffect(() => { setFirstRender(false); var beginAnimation = keyframeInAnimations[props.slide.id]; if (!beginAnimation) return; api.set({ frame: beginAnimation.x, y: beginAnimation.y - 16 }); api.start({ frame: Math.round(beginAnimation.x), y: 44 }); delete keyframeInAnimations[props.slide.id]; }, []); var timelineZoom = getTimelineZoom((st: any) => st.zoom); // drag keyframe var [startOffset, setStartOffset] = useState(0); var [endOffset, setEndOffset] = useState(0); useDrag(({ xy: [x, _y], first }) => { var frame = Math.max(0, Math.round(getFrameAtOffset(x - 240, timelineZoom)) - 1); if (props.slide.type == 'loop') { if (first) { var startFrame = spring.begin.toJSON(); var endFrame = spring.frame.toJSON(); var grabFrameOffset = frame; setStartOffset(startFrame - grabFrameOffset); startOffset = startFrame - grabFrameOffset; setEndOffset(endFrame - grabFrameOffset); endOffset = endFrame - grabFrameOffset; } api.start({ begin: frame + startOffset, frame: frame + endOffset }); modifySlide({ frame: frame + endOffset }); modifySlide({ beginFrame: frame + startOffset }); } else { api.start({ frame }); modifySlide({ frame }); } }, { domTarget: dragRef, eventOptions: { passive: false } }); if (props.slide.type == 'loop') { // loop start useDrag(({ xy: [x, _y] }) => { var frame = Math.max(0, Math.round(getFrameAtOffset(x - 240, timelineZoom)) - 1); api.start({ begin: frame }); modifySlide({ beginFrame: frame }); }, { domTarget: loopStartRef, eventOptions: { passive: false } }); // loop end useDrag(({ xy: [x, _y] }) => { var frame = Math.max(0, Math.round(getFrameAtOffset(x - 240, timelineZoom)) - 1); api.start({ frame }); modifySlide({ frame }); }, { domTarget: loopEndRef, eventOptions: { passive: false } }); } var mouseUpListener = useRef(null); useDrag(({ last }) => { if (!last) return; updateTimeline(); }, { domTarget: mouseUpListener, eventOptions: { passive: false } }); return
{props.slide.type == 'loop' ?
: }
; } function TimelineEditor(props: { player: TimedVideoPlayer; selectedTool: string; }) { var timelineZoom = getTimelineZoom((st: any) => st.zoom); var timelineLabels = useTimelineLabels((st: any) => st.labels); var setTimelineLabels = useTimelineLabels((st: any) => st.setLabels); var workingTimeline = useWorkingTimeline((st: any) => st.timeline); var setWorkingTimeline = useWorkingTimeline((st: any) => st.setTimeline); var refreshWorkingTimline = useWorkingTimeline((st: any) => st.refreshLiveTimeline); var setFrame = useFrame((st: any) => st.setFrame); useEffect(() => { props.player.addEventListener('TimedVideoPlayerOnFrame', (event: CustomEvent) => { setFrame(event.detail); scrubberSpring.start({ frame: event.detail }); }); }, []); useEffect(() => { props.player.addEventListener('TimedVideoPlayerSlide', (event: CustomEvent) => { document.querySelectorAll('.keyframes .frame').forEach(el => { el.classList.remove('current'); if (event.detail && el.id == 'slide-' + (event.detail as slide).id) { el.classList.add('current'); } }); }); }, []); // timeline canvas stuff useEffect(() => { var canvas = document.getElementById('timeScaleCanvas') as HTMLCanvasElement; var ctx = canvas.getContext('2d'); var css = (varname: string) => getComputedStyle(document.body).getPropertyValue(varname).trim(); var baseColor = css('--c100'); var frameColor = css('--c250'); var markerFrame = css('--c400'); function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); var labels: Array = []; var offset = document.querySelector('.timeline .timelineInner').scrollLeft; var frameWidth = Number( getComputedStyle(document.querySelector('.timeline')).getPropertyValue('--zoom').trim(), ); var d = true; var a = 0; var ns = [300, 150, 120, 90, 60, 30, 30, 30, 15, 15, 10, 10, 10]; var everyN = ns[Math.floor(frameWidth)]; for (var x = -offset; x < canvas.width + offset; x += frameWidth) { ctx.fillStyle = baseColor; var rect = [Math.round(x + (frameWidth - 2) / 2), 28, 2, canvas.height]; var drawFrame = false; var marker = false; if (frameWidth >= 6) { ctx.fillStyle = d ? baseColor : frameColor; rect = [x, 28, frameWidth, canvas.height]; drawFrame = !d; } if (a % everyN == 0) { ctx.fillStyle = markerFrame; drawFrame = true; marker = true; } if (drawFrame) { ctx.fillRect(Math.round(rect[0]), Math.round(rect[1]), Math.round(rect[2]), Math.round(rect[3])); if (marker) { var frame = Math.round(x / frameWidth + offset / frameWidth + 1); labels.push( , ); } } d = !d; a++; } setTimelineLabels(labels); requestAnimationFrame(draw); } draw(); function onresize() { var size = document.querySelector('.timeline .timelineInner'); canvas.width = size.clientWidth; canvas.height = size.clientHeight; } onresize(); window.addEventListener('resize', onresize); }, []); // timeline scrubber var scrubberDragRef = useRef(null); var [scrubberPos, scrubberSpring] = useSpring( () => ({ frame: 0, config: { mass: 0.5, tension: 500, friction: 20 }, }), ); useDrag(({ xy: [x, _y] }) => { var frame = Math.max(0, Math.round(getFrameAtOffset(x - 240, timelineZoom)) - 1); setFrame(frame); scrubberSpring.start({ frame }); if (props.player.player) { var player = props.player.player; player.currentTime = props.player.frameToTimestamp(frame + 1); } }, { domTarget: scrubberDragRef, eventOptions: { passive: false } }); // slide placement ghost var [ghost, ghostApi] = useSpring(() => ({ x: 0, y: 0, config: { mass: 0.5, tension: 500, friction: 20 }, })); useEffect(() => { document.querySelector('.timeline').addEventListener('mousemove', (e: MouseEvent) => { var rect = document.querySelector('.timeline').getBoundingClientRect(); var x = e.clientX - rect.left; var y = e.clientY - rect.top; ghostApi.start({ x, y }); }); }, []); // selection var [selectionActive, setSelectionActive] = useState(false); var [selectionPlaced, setSelectionPlaced] = useState(false); var [selectionHidden, setSelectionHidden] = useState(true); var [selectionLeftType, setSelectionLeftType] = useState(null); var [selectionRightType, setSelectionRightType] = useState(null); var [selectionPos, selectionPosAPI] = useSpring(() => ({ x1: 0, y1: 0, x2: 0, y2: 0, center: 0, startingFrame: 0, frameWidth: 0, startOffset: 0, widthOffset: 0, config: { mass: 0.5, tension: 500, friction: 20 }, })); var selectionAreaRef = useRef(null); var selectionRef = useRef(null); var [selection, setSelection] = useState([]); useDrag(({ movement: [x, _y], last }) => { if (!selectionPlaced) return; if (selection.length < 1) return; var frameOffset = Math.round(x / zoomToPx(timelineZoom)); selection.forEach((slide: anySlide) => { var api = slideAPIs[slide.id]; switch (slide.type as slideTypes | 'loopBegin') { case 'loopBegin': { if (!api) break; var loop = workingTimeline.find((s: anySlide) => s.id == slide.id) as loopSlide; var begin = loop.beginFrame + frameOffset; api.start({ begin }); if (last) { loop.beginFrame = begin; refreshWorkingTimline(); } break; } default: { if (!api) break; var frame = slide.frame + frameOffset; api.start({ frame }); if (last) { workingTimeline.find((s: anySlide) => s.id == slide.id).frame = frame; refreshWorkingTimline(); } } } if (last) return; var selectionFrame = selection[0].frame; selectionPosAPI.start({ startingFrame: selectionFrame + frameOffset }); }); }, { domTarget: selectionRef, eventOptions: { passive: false } }); useDrag(({ xy: [x, y], initial: [bx, by], first, last, movement: [ox, oy] }) => { if (props.selectedTool != 'cursor') return; var minDistance = 5; // minimal drag distance in pixels to register selection var distanceTraveled = Math.sqrt(ox ** 2 + oy ** 2); if (selectionHidden && distanceTraveled > minDistance) setSelectionHidden(false); if (selectionLeftType) setSelectionLeftType(null); if (selectionRightType) setSelectionRightType(null); if (selectionPlaced) setSelectionPlaced(false); selectionPosAPI.start({ center: 0, startOffset: 0, widthOffset: 0, }); selection = []; setSelection(selection); var timelineInner = document.querySelector('.timeline .timelineInner'); var timelineRects = timelineInner.getBoundingClientRect(); var tx = x - timelineRects.x + timelineInner.scrollLeft; var ty = y - timelineRects.y; var ix = bx - timelineRects.x + timelineInner.scrollLeft; var iy = by - timelineRects.y; var sx = tx - ix; var sy = ty - iy; var x1 = ix + Math.min(0, sx); var y1 = iy + Math.min(0, sy); var x2 = x1 + Math.abs(sx); var y2 = y1 + Math.abs(sy); var zoom = zoomToPx(timelineZoom); var frameWidth = Math.abs(sx) / zoom; var startingFrame = x1 / zoom; selectionPosAPI[first && selectionHidden ? 'set' : 'start']({ x1, y1, x2, y2, startingFrame, frameWidth }); if (!selectionActive) setSelectionActive(true); if (last) { setSelectionActive(false); if (distanceTraveled <= minDistance) setSelectionHidden(true); else { var endingFrame = startingFrame + frameWidth; var expandedTimeline = new Array(...player.timeline.slides); for (let i = 0; i < expandedTimeline.length; i++) { var slide = expandedTimeline[i]; if (slide.type != 'loop') continue; var beginFrame = (slide as loopSlide).beginFrame; expandedTimeline.splice(i, 0, new loopBeginSlide(beginFrame)); expandedTimeline[i].id = expandedTimeline[i + 1].id; i++; } var keyframesInSelection = expandedTimeline.filter(slide => slide.frame >= Math.floor(startingFrame) && slide.frame <= Math.ceil(endingFrame) ); if (keyframesInSelection.length < 1) { setSelectionHidden(true); return; } selection = keyframesInSelection; setSelection(selection); var left = keyframesInSelection[0]; var right = keyframesInSelection[keyframesInSelection.length - 1]; var [startOffset, widthOffset] = calculateSelectionOffsets(left.type, right.type); selectionPosAPI.start({ y1: 50, y2: 62, startingFrame: left.frame, frameWidth: right.frame - left.frame, center: 0.5, startOffset, widthOffset, }); setSelectionLeftType(left.type); setSelectionRightType(right.type); setSelectionPlaced(true); } } }, { domTarget: selectionAreaRef, eventOptions: { passive: false } }); useEffect(() => { var delkeys = ['del', 'backspace']; mousetrap.bind(delkeys, () => { if (!selectionPlaced) return; selection.forEach((slide: anySlide) => { if (!slideTypes.includes(slide.type)) return; var index = workingTimeline.findIndex((s: anySlide) => s?.id == slide.id); if (index == -1) return; delete workingTimeline[index]; }); refreshWorkingTimline(); setSelectionPlaced(false); setSelectionHidden(true); setSelection([]); }); return () => { mousetrap.unbind(delkeys); }; }, [selectionPlaced, workingTimeline]); return <> { // place new keyframe var offset = -4; // keyframe offset var x = event.clientX - 240 + offset; var frame = getFrameAtOffset(x, timelineZoom) - 0.5; var slide = new toolToSlide[props.selectedTool](Math.round(frame)); workingTimeline.push(slide); setWorkingTimeline(workingTimeline); keyframeInAnimations[slide.id] = { x: frame, y: event.clientY - window.innerHeight + 210, }; refreshWorkingTimline(); }} />
{workingTimeline.map((slide: anySlide) => )}
} />
} />
; } function DefaultSettings() { var setPlaying = usePlaying((st: any) => st.setPlaying); var setWorkingTimeline = useWorkingTimeline((st: any) => st.setTimeline); return <>

Presentation settings

Controls
Allow remote control during presentation
Cool temporary buttons { var file = event.target.files[0]; if (!file) return; var reader = new FileReader(); reader.addEventListener('load', ev => { player.loadVideo(ev.target.result as string); player.player.addEventListener('play', () => setPlaying(true)); player.player.addEventListener('pause', () => setPlaying(false)); }); reader.readAsDataURL(file); }} />
; } export default function Index() { var timelineZoom = getTimelineZoom((st: any) => st.zoom); var setTimelineZoom = getTimelineZoom((st: any) => st.setZoom); var frame = useFrame((st: any) => st.currentFrame); var [tool, setTool] = useState('cursor'); var playing = usePlaying((st: any) => st.playing); var mouseX = 0; useEffect(() => { var videoEL = document.getElementById('player') as HTMLVideoElement; player.registerPlayer(videoEL); }, []); function zoomAroundPoint(newZoom: number, pivot: number) { var timeline = document.querySelector('.timeline .timelineInner'); var frame = getFrameAtOffset(pivot, timelineZoom); var newOffset = (frame * zoomToPx(newZoom)) - pivot; timeline.scrollLeft = newOffset; setTimelineZoom(newZoom); timelineZoom = newZoom; } useEffect(() => { document.querySelector('.timeline').addEventListener('wheel', (e: WheelEvent) => { if (!e.ctrlKey && !e.altKey) return; e.preventDefault(); var newZoom = Math.min(1, Math.max(0, timelineZoom + (-e.deltaY / 1000))); zoomAroundPoint(newZoom, mouseX); }, { passive: false }); }, []); useEffect(() => { var canvas = document.querySelector('.timeline .timeScale'); window.addEventListener('mousemove', e => { var rect = canvas.getBoundingClientRect(); mouseX = e.clientX - rect.x; }); }, []); useEffect(() => { var preventDefault = (e: Event) => e.preventDefault(); document.addEventListener('gesturestart', preventDefault); document.addEventListener('gesturechange', preventDefault); }, []); return <>

pressure

} onClick={() => { player.slide = 0; player.previous(); }} /> { player.next(); player.player.play(); }} children={} style={{ '--ani-state': playing ? 'skip' : 'play' } as CSSProperties} /> } onClick={() => player.previous()} /> } onClick={() => { player.next(); // TODO: fix jank here player.next(); player.previous(); }} />
} onClick={() => document.body.requestFullscreen()} />
@{player.framerate}fps

{player.frameToTimestampString(frame, false)}

{ if (newTool === null) return; setTool(newTool); }} > } /> } /> } /> } />
} /> } />
{ var center = document.querySelector('.timeline .timelineInner').clientWidth / 2; zoomAroundPoint(newValue as number, center); }} min={0} step={0.00000001} max={1} aria-labelledby='continuous-slider' />
; }