VOD Chat Replay — Frontend Integration
When a livestream ends and the video becomes a VOD, the player should replay the chat in sync with playback. This document explains how to consume the replay endpoint and drive it from the player.
Endpoint
GET /videos/{id}/messages/replay?from={long}&to={long}
GET /mobile/videos/{id}/messages/replay?from={long}&to={long}
Path params
id— the encoded video id (same id used elsewhere in the API).
Query params
from— inclusive lower bound onMessage.Timestamp.to— exclusive upper bound onMessage.Timestamp.
Response — ListMessageDto[], ordered ascending by timestamp:
[
{
"id": "01HX...",
"user": { "id": "u_123", "name": "alice", "nickname": "Alice" },
"content": "lol",
"timestamp": 1717245001234,
"createdAt": "2026-06-01T12:30:01.234Z"
}
]
Returned chunks are immutable and cached server-side for 7 days, so identical (id, from, to) calls are cheap.
Timestamp model
Message.Timestamp is the absolute timestamp the message was sent during the live stream (same unit it was originally recorded in — pass it back to the API as-is, do not convert).
To map the player's currentTime (seconds since stream start) to a Timestamp, you need the stream's reference start. Use one of:
liveStartDatefrom the video DTO (VideoStreamDetails.LiveStartDate) — convert to the same unit asMessage.Timestampand addcurrentTime.- The first message's timestamp — if you don't have
liveStartDate, fetch a tiny initial chunk and treat the lowesttimestampas your origin.
const streamStartMs = new Date(video.streamDetails.liveStartDate).getTime();
const playbackToTimestamp = (currentTimeSec: number) =>
streamStartMs + Math.floor(currentTimeSec * 1000);
Adjust
* 1000if the backend storesTimestampas seconds rather than milliseconds — confirm against a sample row before shipping.
Replay loop
The replay should pull chat in small chunks ahead of the playhead and emit messages to the UI as the player crosses each timestamp.
Recommended parameters
| Param | Suggested value | Why |
|---|---|---|
CHUNK_SECONDS |
30 |
One request covers ~30s of chat |
PREFETCH_AHEAD_SECONDS |
10 |
Fetch the next chunk this far before exhausting the buffer |
MAX_BUFFER_SECONDS |
60 |
Stop prefetching past this horizon |
Pseudo-implementation
class VodChatReplay {
private buffer: ListMessage[] = [];
private fetchedUntil = 0; // last `to` we've fetched, in Timestamp units
private lastEmittedIndex = -1;
constructor(
private videoId: string,
private streamStartMs: number,
private api: PicTvApi,
) {}
// Call on play / seek / when the chunk runs out.
async ensureBuffered(currentTimeSec: number) {
const head = this.streamStartMs + currentTimeSec * 1000;
const horizon = head + MAX_BUFFER_SECONDS * 1000;
while (this.fetchedUntil < horizon) {
const from = Math.max(this.fetchedUntil, head);
const to = from + CHUNK_SECONDS * 1000;
const chunk = await this.api.getReplayMessages(this.videoId, from, to);
this.buffer.push(...chunk);
this.fetchedUntil = to;
}
}
// Call on every `timeupdate` from the player.
tick(currentTimeSec: number, onMessage: (m: ListMessage) => void) {
const head = this.streamStartMs + currentTimeSec * 1000;
// Top up the buffer when we're getting close to the end.
if (this.fetchedUntil - head < PREFETCH_AHEAD_SECONDS * 1000) {
this.ensureBuffered(currentTimeSec);
}
// Emit any buffered messages whose timestamp has now passed.
while (
this.lastEmittedIndex + 1 < this.buffer.length &&
this.buffer[this.lastEmittedIndex + 1].timestamp <= head
) {
this.lastEmittedIndex += 1;
onMessage(this.buffer[this.lastEmittedIndex]);
}
}
// Call on seek (forward or backward).
reset() {
this.buffer = [];
this.fetchedUntil = 0;
this.lastEmittedIndex = -1;
}
}
Wiring to the player
const replay = new VodChatReplay(video.id, streamStartMs, api);
player.on('play', () => replay.ensureBuffered(player.currentTime));
player.on('timeupdate', () =>
replay.tick(player.currentTime, (m) => chatStore.append(m)),
);
player.on('seeking', () => {
chatStore.clear();
replay.reset();
});
Edge cases
- Seek backward — clear the chat UI and reset the replay buffer; let it refetch.
- Seek far forward — same: reset, then
ensureBufferedat the new position. - Pause — keep the buffer; tick will resume naturally on play.
- Playback rate > 1× —
tickis timestamp-driven, so messages still emit in order; you may want to drop emits if more than N messages fall in one tick. - Empty chunks — normal during quiet stretches; the loop still advances
fetchedUntil, so no extra work. - End of stream — when
fromexceedsliveEndDate, subsequent calls return[]. Stop calling oncehead > liveEndDate.
Switching from live to VOD chat
In the player's chat component, branch on video.type:
Livestream→ existing websocket /GET /videos/{id}/messagesflow.Vod→ instantiateVodChatReplayinstead.
No state needs to migrate between the two — when a stream ends and the page reloads, the player simply mounts the replay flow.