Timeline
The timeline is the row under the video with one segment per scene, each as long as its scene plays. It is how a viewer scrubs, and with editing on it is also the whole editor: hover a segment to switch that scene off, move it earlier or later, or open it for editing. There is no separate scene list to keep in sync.
Hover a segment (tap it on a phone): arrows, a pencil and a switch.
Editing from the player
WrappedPlayer draws the timeline for you. Pass it editing and the hover card of every segment gets its controls. The player never changes the cut itself: each control calls you, you change the config, and the scenes you pass back are the new video.
import { type CSSProperties, useMemo, useState } from "react";
import { arrangeScenes, type SceneInstance, type WrappedConfig, type WrappedSubject } from "@react-wrapped/core";
import { WrappedPlayer } from "@react-wrapped/player";
const ROOM: CSSProperties = { display: "grid", height: 560 };
export function EditableCut({
subject,
seed,
scenes,
config,
onConfigChange,
}: {
subject: WrappedSubject;
seed: string;
scenes: readonly SceneInstance[];
config: WrappedConfig;
onConfigChange: (next: WrappedConfig) => void;
}) {
const [asked, setAsked] = useState<string | null>(null);
// Every scene in the viewer's order, the switched-off ones too: the timeline shows them all.
const ordered = useMemo(() => arrangeScenes(scenes, config.order), [scenes, config.order]);
// Only what is still on gets played. Memoized: the player rebuilds its timeline when this array changes.
const playing = useMemo(
() => ordered.filter((scene) => !config.hidden.includes(scene.key)),
[ordered, config.hidden],
);
const keys = ordered.map((scene) => scene.key);
const editing = {
scenes: ordered,
hidden: config.hidden,
onToggle: (key: string, on: boolean) =>
onConfigChange({
...config,
hidden: on ? config.hidden.filter((hidden) => hidden !== key) : [...config.hidden, key],
}),
onMove: (key: string, by: -1 | 1) => {
const from = keys.indexOf(key);
const to = from + by;
if (from === -1 || to < 0 || to >= keys.length) return;
const order = [...keys];
[order[from], order[to]] = [order[to], order[from]];
onConfigChange({ ...config, order });
},
// The pencil. Open your own editor here, or the library's `SceneEditor`.
onEdit: setAsked,
};
return (
<div>
<div style={ROOM}>
<WrappedPlayer subject={subject} seed={seed} scenes={playing} config={config} editing={editing} />
</div>
<output>
{`hidden: [${config.hidden.join(", ")}] order: [${config.order.join(", ")}]`}
{asked ? ` asked to edit: ${asked}` : ""}
</output>
</div>
);
}| Key | Type | What it does |
|---|---|---|
scenes | readonly SceneInstance[] | Every scene in the viewer's order, the switched-off ones too. Only on the player's editing: the timeline takes it as its own scenes. |
hidden | readonly string[] | The keys of the scenes that are switched off. |
onToggle | (key: string, on: boolean) => void | The switch in the hover card was flipped. |
onMove | (key: string, by: -1 | 1) => void | An arrow was pressed: one place earlier, or one place later. |
onEdit | (key: string) => void | The pencil was pressed. Open an editor for that scene. |
What the viewer gets
- Scrub. Click or drag anywhere on the row. The video pauses on that moment.
- A still. Hovering a segment shows the finished scene, its name and how long it plays. On touch a tap opens the same card.
- The switch. A scene switched off shrinks to a hollow sliver and stops playing. It stays on the row, so it can be switched back on. The last scene still on cannot be switched off.
- The arrows move a scene one place earlier or later.
- The pencil is there when the scene has texts to rewrite or a transition to pick.
- Rarity. A scene placed with
dropwears its rarity colour, and glows once when it joins the row, so a find is easy to spot.
Keeping new scenes in place
arrangeScenes(scenes, order) from @react-wrapped/core puts scenes in the viewer's order. A scene the viewer has never placed, such as one the scan just found, goes right after the scene you put before it, so a find lands in its story slot even after the cut was reordered by hand. Your last scene stays last.
Without the player
Timeline is exported for a transport of your own. It needs what useWrappedTimeline returns: the marks, the duration and a seek function, plus a ref the engine fills the segments through. This is the whole of a small player with its own play button:
import { type CSSProperties, useRef } from "react";
import {
type SceneInstance,
useWrappedTimeline,
WRAPPED_FORMATS,
type WrappedConfig,
WrappedStage,
type WrappedSubject,
} from "@react-wrapped/core";
import { Timeline } from "@react-wrapped/player";
/** How wide the frame is drawn on the page. The stage itself is always full size and scaled to this. */
const WIDTH = 280;
export function OwnTransport({
subject,
seed,
scenes,
config,
}: {
subject: WrappedSubject;
seed: string;
scenes: readonly SceneInstance[];
config: WrappedConfig;
}) {
const stageRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const clockRef = useRef<HTMLSpanElement>(null);
// The engine behind `WrappedPlayer`: it builds the timeline and writes the clock and the segments through the refs.
const { playing, marks, toggle, seekTime, duration } = useWrappedTimeline({
stageRef,
scenes,
config,
seed,
trackRef,
clockRef,
});
const format = WRAPPED_FORMATS[config.format];
const scale = WIDTH / format.width;
const frame: CSSProperties = {
position: "relative",
width: WIDTH,
height: format.height * scale,
overflow: "hidden",
borderRadius: 16,
};
return (
<div>
<div style={frame}>
<WrappedStage stageRef={stageRef} subject={subject} scenes={scenes} config={config} scale={scale} />
</div>
<div role="group" aria-label="Transport">
<button type="button" onClick={toggle}>
{playing ? "Pause" : "Play"}
</button>
<Timeline
trackRef={trackRef}
scenes={scenes}
marks={marks}
duration={duration}
config={config}
onSeek={seekTime}
/>
<span ref={clockRef}>0:00 / 0:00</span>
</div>
</div>
);
}Props
| Prop | Type | What it does |
|---|---|---|
trackRef | RefObject<HTMLDivElement | null> | A ref for the row of segments. The engine fills each segment's bar through it as the video plays. |
scenes | readonly SceneInstance[] | Every scene in order. With editing, that includes the ones switched off. |
marks | readonly SceneMark[] | Where each playing scene starts. From useWrappedTimeline. |
duration | () => number | The length of the video in seconds. From useWrappedTimeline. |
config | WrappedConfig | For the format of the hover still, the speed and the rarity colours. |
editing? | TimelineEditing | The creator's controls in the hover card. Leave it out for a plain scrubber. |
onSeek | (seconds: number) => void | Called while the viewer clicks or drags along the row. |
className? | string | A class for the timeline's root. |
classNames? | SlotClassNames<TimelineSlot> | A class per part, by slot name: "timeline-segment-bar", "timeline-preview". See styling. |
