Skip to content

Commit

Permalink
(experimental) Playback control prototype (#251)
Browse files Browse the repository at this point in the history
* Start playback control

* Dimension tweaks

* Use loop start

* eslint
  • Loading branch information
brentyi authored Jul 22, 2024
1 parent f236041 commit c40da3e
Showing 1 changed file with 202 additions and 58 deletions.
260 changes: 202 additions & 58 deletions src/viser/client/src/FilePlayback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@ import { decodeAsync, decode } from "@msgpack/msgpack";
import { Message } from "./WebsocketMessages";
import { decompress } from "fflate";

import { useContext, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { ViewerContext } from "./App";
import { Progress, useMantineTheme } from "@mantine/core";

interface SerializedMessages {
loopStartIndex: number | null;
durationSeconds: number;
messages: [number, Message][];
}
import {
ActionIcon,
NumberInput,
Paper,
Progress,
Select,
Slider,
Tooltip,
useMantineTheme,
} from "@mantine/core";
import {
IconPlayerPauseFilled,
IconPlayerPlayFilled,
} from "@tabler/icons-react";

/** Download, decompress, and deserialize a file, which should be serialized
* via msgpack and compressed via gzip. Also takes a hook for status updates. */
Expand Down Expand Up @@ -61,68 +68,205 @@ async function deserializeGzippedMsgpackFile<T>(
});
}

interface SerializedMessages {
loopStartIndex: number | null;
durationSeconds: number;
messages: [number, Message][];
}

export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) {
const viewer = useContext(ViewerContext)!;
const messageQueueRef = viewer.messageQueueRef;

const darkMode = viewer.useGui((state) => state.theme.dark_mode);
const [status, setStatus] = useState({ downloaded: 0.0, total: 0.0 });
const [loaded, setLoaded] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState("1x");
const [paused, setPaused] = useState(false);
const [recording, setRecording] = useState<SerializedMessages | null>(null);

const [currentTime, setCurrentTime] = useState(0.0);

const theme = useMantineTheme();

useEffect(() => {
deserializeGzippedMsgpackFile<SerializedMessages>(fileUrl, setStatus).then(
(recording) => {
let messageIndex = 0;

function continuePlayback() {
setLoaded(true);
const currentTimeSeconds = recording.messages[messageIndex][0];
while (currentTimeSeconds >= recording.messages[messageIndex][0]) {
messageQueueRef.current.push(recording.messages[messageIndex][1]);
messageIndex += 1;

// Either finish playback or loop.
if (messageIndex === recording.messages.length) {
if (recording.loopStartIndex === null) return;
messageIndex = recording.loopStartIndex;
setTimeout(
continuePlayback,
(recording.durationSeconds - currentTimeSeconds) * 1000.0,
);
return;
}
}

// Handle next set of frames.
setTimeout(
continuePlayback,
(recording.messages[messageIndex][0] - currentTimeSeconds) * 1000.0,
);
}
setTimeout(continuePlayback, recording.messages[0][0] * 1000.0);
},
setRecording,
);
}, []);

return (
<div
style={{
position: "fixed",
zIndex: 1000,
top: 0,
bottom: 0,
left: 0,
right: 0,
display: loaded ? "none" : "block",
backgroundColor: darkMode ? theme.colors.dark[9] : "#fff",
}}
>
<Progress
value={(status.downloaded / status.total) * 100.0}
radius={0}
transitionDuration={0}
/>
</div>
const playbackMutable = useRef({ currentTime: 0.0, currentIndex: 0 });

const updatePlayback = useCallback(() => {
if (recording === null) return;
const mutable = playbackMutable.current;

// We have messages with times: [0.0, 0.01, 0.01, 0.02, 0.03]
// We have our current time: 0.02
// We want to get of a slice of all message _until_ the current time.
for (
;
mutable.currentIndex < recording.messages.length &&
recording.messages[mutable.currentIndex][0] <= mutable.currentTime;
mutable.currentIndex++
) {
const message = recording.messages[mutable.currentIndex][1];
messageQueueRef.current.push(message);
}

if (mutable.currentTime >= recording.durationSeconds) {
mutable.currentIndex = recording.loopStartIndex!;
mutable.currentTime = recording.messages[recording.loopStartIndex!][0];
}
setCurrentTime(mutable.currentTime);
}, [recording]);

useEffect(() => {
const playbackMultiplier = parseFloat(playbackSpeed); // '0.5x' -> 0.5
if (recording !== null && !paused) {
const interval = setInterval(() => {
playbackMutable.current.currentTime +=
(1.0 / 120.0) * playbackMultiplier;
updatePlayback();
}, 1000.0 / 120.0);
return () => clearInterval(interval);
}
}, [
updatePlayback,
recording,
paused,
playbackSpeed,
messageQueueRef,
setCurrentTime,
]);

// Pause/play with spacebar.
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.code === "Space") {
setPaused(!paused);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [paused]); // Empty dependency array ensures this runs once on mount and cleanup on unmount

const updateCurrentTime = useCallback(
(value: number) => {
if (value < playbackMutable.current.currentTime) {
// Going backwards is more expensive...
playbackMutable.current.currentIndex = recording!.loopStartIndex!;
}
playbackMutable.current.currentTime = value;
setCurrentTime(value);
setPaused(true);
updatePlayback();
},
[recording],
);

if (recording === null) {
return (
<div
style={{
position: "fixed",
zIndex: 1,
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: darkMode ? theme.colors.dark[9] : "#fff",
}}
>
<Progress
value={(status.downloaded / status.total) * 100.0}
radius={0}
transitionDuration={0}
/>
</div>
);
} else {
return (
<Paper
radius="xs"
shadow="0.1em 0 1em 0 rgba(0,0,0,0.1)"
style={{
position: "fixed",
bottom: "0.75em",
left: "50%",
transform: "translateX(-50%)",
width: "20em",
maxWidth: "95%",
zIndex: 1,
padding: "0.5em",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.375em",
}}
>
<ActionIcon size="xs" variant="subtle">
{paused ? (
<IconPlayerPlayFilled onClick={() => setPaused(false)} />
) : (
<IconPlayerPauseFilled onClick={() => setPaused(true)} />
)}
</ActionIcon>
<NumberInput
size="xs"
hideControls
value={currentTime.toFixed(1)}
step={0.01}
styles={{
wrapper: {
width:
(recording.durationSeconds.toFixed(1).length * 0.8).toString() +
"em",
},
input: {
fontFamily: theme.fontFamilyMonospace,
padding: "0.5em",
minHeight: "1.25rem",
height: "1.25rem",
},
}}
onChange={(value) =>
updateCurrentTime(
typeof value === "number" ? value : parseFloat(value),
)
}
/>
<Slider
thumbSize={0}
radius="xs"
step={1e-4}
style={{ flexGrow: 1 }}
min={0}
max={recording.durationSeconds}
value={currentTime}
onChange={updateCurrentTime}
styles={{ thumb: { display: "none" } }}
/>
<Tooltip zIndex={10} label={"Playback speed"} withinPortal>
<Select
size="xs"
value={playbackSpeed}
onChange={(val) => (val === null ? null : setPlaybackSpeed(val))}
radius="xs"
data={["0.5x", "1x", "2x", "4x", "8x"]}
styles={{
wrapper: { width: "3.25em" },
input: {
padding: "0.5em",
minHeight: "1.25rem",
height: "1.25rem",
},
}}
comboboxProps={{ zIndex: 5, width: "5.25em" }}
/>
</Tooltip>
</Paper>
);
}
}

0 comments on commit c40da3e

Please sign in to comment.