mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
fix stuck replayer bug
Some checks failed
DB migrations are backwards-compatible / Check if migrations changed (push) Has been cancelled
DB migrations are backwards-compatible / Test migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migrations are backwards-compatible / No migration changes (skipped) (push) Has been cancelled
Some checks failed
DB migrations are backwards-compatible / Check if migrations changed (push) Has been cancelled
DB migrations are backwards-compatible / Test migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migrations are backwards-compatible / No migration changes (skipped) (push) Has been cancelled
This commit is contained in:
parent
5868a35e6e
commit
fa3242e504
@ -483,7 +483,25 @@ export default function PageClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msRef.current.hasFullSnapshotByTab.has(tabKey)) return;
|
||||
if (!msRef.current.hasFullSnapshotByTab.has(tabKey)) {
|
||||
// Last-resort: scan accumulated events for a FullSnapshot that the
|
||||
// chunk-level detection may have missed (eg. due to race conditions or
|
||||
// type coercion). rrweb FullSnapshot is event type 2.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const hasSnapshot = eventsSnapshot.some(e => (e as any).type === 2 || (e as any).type === "2");
|
||||
if (!hasSnapshot) return;
|
||||
// Patch the machine state so subsequent checks pass.
|
||||
actRef.current({
|
||||
type: "CHUNK_LOADED",
|
||||
generation: gen,
|
||||
tabKey,
|
||||
hasFullSnapshot: true,
|
||||
loadedDurationMs: eventsSnapshot.length >= 2
|
||||
? eventsSnapshot[eventsSnapshot.length - 1].timestamp - eventsSnapshot[0].timestamp
|
||||
: 0,
|
||||
hadEventsBeforeThisChunk: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { Replayer } = await import("rrweb");
|
||||
@ -617,6 +635,11 @@ export default function PageClient() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
// Replayer doesn't exist — try to create it so REPLAYER_READY
|
||||
// can resume playback. This covers race conditions where the
|
||||
// replayer hasn't been initialised yet when play is requested.
|
||||
runAsynchronously(() => ensureReplayerForTab(effect.tabKey, msRef.current.generation), { noErrorLogging: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -798,7 +821,7 @@ export default function PageClient() {
|
||||
|
||||
const hasFullSnapshot = !msRef.current.hasFullSnapshotByTab.has(tabKey)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
&& events.some(e => (e as any).type === 2);
|
||||
&& events.some(e => Number((e as any).type) === 2);
|
||||
|
||||
let loadedDurationMs = 0;
|
||||
if (prev.length >= 2) {
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
findNextTabStartAfterGlobalOffset,
|
||||
ALLOWED_PLAYER_SPEEDS,
|
||||
DEFAULT_REPLAY_SETTINGS,
|
||||
STALL_THRESHOLD_MS,
|
||||
type ReplayState,
|
||||
type ReplayAction,
|
||||
type StreamInfo,
|
||||
@ -253,6 +254,64 @@ describe("session-replay-machine", () => {
|
||||
expect(hasEffect(effects, "play_replayer")).toBe(true);
|
||||
});
|
||||
|
||||
it("switches active tab when current active has no snapshot", () => {
|
||||
const state = twoTabReadyState({
|
||||
activeTabKey: "a",
|
||||
});
|
||||
// Active tab "a" has no full snapshot
|
||||
state.hasFullSnapshotByTab.delete("a");
|
||||
state.replayerReady.delete("a");
|
||||
state.replayerReady.delete("b");
|
||||
state.hasFullSnapshotByTab.delete("b");
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "CHUNK_LOADED",
|
||||
generation: 1,
|
||||
tabKey: "b",
|
||||
hasFullSnapshot: true,
|
||||
loadedDurationMs: 500,
|
||||
hadEventsBeforeThisChunk: false,
|
||||
});
|
||||
expect(s.activeTabKey).toBe("b");
|
||||
});
|
||||
|
||||
it("does NOT switch active tab when active already has a snapshot", () => {
|
||||
const state = twoTabReadyState({
|
||||
activeTabKey: "a",
|
||||
});
|
||||
// Active tab "a" already has full snapshot
|
||||
state.replayerReady.delete("b");
|
||||
state.hasFullSnapshotByTab.delete("b");
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "CHUNK_LOADED",
|
||||
generation: 1,
|
||||
tabKey: "b",
|
||||
hasFullSnapshot: true,
|
||||
loadedDurationMs: 500,
|
||||
hadEventsBeforeThisChunk: false,
|
||||
});
|
||||
expect(s.activeTabKey).toBe("a");
|
||||
});
|
||||
|
||||
it("does NOT switch on subsequent snapshots for same tab", () => {
|
||||
const state = twoTabReadyState({
|
||||
activeTabKey: "a",
|
||||
});
|
||||
// Tab "b" already has a full snapshot
|
||||
// Active tab "a" has no full snapshot
|
||||
state.hasFullSnapshotByTab.delete("a");
|
||||
state.replayerReady.delete("a");
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "CHUNK_LOADED",
|
||||
generation: 1,
|
||||
tabKey: "b",
|
||||
hasFullSnapshot: true,
|
||||
loadedDurationMs: 2000,
|
||||
hadEventsBeforeThisChunk: true,
|
||||
});
|
||||
// Tab b already had a snapshot, so no switch needed
|
||||
expect(s.activeTabKey).toBe("a");
|
||||
});
|
||||
|
||||
it("stays buffering when not enough data", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "buffering",
|
||||
@ -306,6 +365,27 @@ describe("session-replay-machine", () => {
|
||||
expect(hasEffect(effects, "play_replayer")).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-plays non-active tab when active tab is stuck (no full snapshot)", () => {
|
||||
const state = twoTabReadyState({
|
||||
autoPlayTriggered: false,
|
||||
activeTabKey: "a",
|
||||
});
|
||||
// Active tab "a" has no full snapshot — it's stuck
|
||||
state.hasFullSnapshotByTab.delete("a");
|
||||
state.replayerReady.delete("a");
|
||||
state.replayerReady.delete("b");
|
||||
const { state: s, effects } = dispatch(state, {
|
||||
type: "REPLAYER_READY",
|
||||
generation: 1,
|
||||
tabKey: "b",
|
||||
});
|
||||
// Should switch to "b" and auto-play
|
||||
expect(s.activeTabKey).toBe("b");
|
||||
expect(s.autoPlayTriggered).toBe(true);
|
||||
expect(s.playbackMode).toBe("playing");
|
||||
expect(hasEffect(effects, "play_replayer")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not auto-play when already triggered", () => {
|
||||
const state = twoTabReadyState({
|
||||
autoPlayTriggered: true,
|
||||
@ -509,6 +589,21 @@ describe("session-replay-machine", () => {
|
||||
expect(hasEffect(effects, "play_replayer")).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to a ready tab when active tab has no replayer", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
activeTabKey: "a",
|
||||
pausedAtGlobalMs: 0,
|
||||
});
|
||||
// Active tab "a" has no full snapshot — can't play
|
||||
state.hasFullSnapshotByTab.delete("a");
|
||||
state.replayerReady.delete("a");
|
||||
const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
|
||||
expect(s.activeTabKey).toBe("b");
|
||||
expect(s.playbackMode).toBe("playing");
|
||||
expect(hasEffect(effects, "play_replayer")).toBe(true);
|
||||
});
|
||||
|
||||
it("buffers when trying to play beyond loaded data", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
@ -523,6 +618,32 @@ describe("session-replay-machine", () => {
|
||||
expect(s.playbackMode).toBe("buffering");
|
||||
expect(s.autoResumeAfterBuffering).toBe(true);
|
||||
});
|
||||
|
||||
it("emits schedule_buffer_poll when entering buffering (no snapshot)", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
activeTabKey: "a",
|
||||
phase: "downloading",
|
||||
});
|
||||
state.hasFullSnapshotByTab.clear();
|
||||
state.replayerReady.clear();
|
||||
const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
|
||||
expect(s.playbackMode).toBe("buffering");
|
||||
expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true);
|
||||
});
|
||||
|
||||
it("emits schedule_buffer_poll when entering buffering (data not loaded)", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
activeTabKey: "a",
|
||||
pausedAtGlobalMs: 3500,
|
||||
phase: "downloading",
|
||||
});
|
||||
state.loadedDurationByTabMs.set("a", 2000);
|
||||
const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
|
||||
expect(s.playbackMode).toBe("buffering");
|
||||
expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEEK", () => {
|
||||
@ -779,6 +900,26 @@ describe("session-replay-machine", () => {
|
||||
expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not resume to playing when active tab has no snapshot", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "buffering",
|
||||
activeTabKey: "a",
|
||||
bufferingAtGlobalMs: 1000,
|
||||
phase: "ready",
|
||||
});
|
||||
// Tab a has no full snapshot — can't play
|
||||
state.hasFullSnapshotByTab.delete("a");
|
||||
state.replayerReady.delete("a");
|
||||
state.loadedDurationByTabMs.set("a", 5000);
|
||||
const { state: s, effects } = dispatch(state, {
|
||||
type: "BUFFER_CHECK",
|
||||
generation: 1,
|
||||
tabKey: "a",
|
||||
});
|
||||
expect(s.playbackMode).toBe("paused");
|
||||
expect(hasEffect(effects, "pause_all")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores stale generation", () => {
|
||||
const state = twoTabReadyState({ playbackMode: "buffering" });
|
||||
const { effects } = dispatch(state, {
|
||||
@ -809,6 +950,51 @@ describe("session-replay-machine", () => {
|
||||
expect(s.bufferingAtGlobalMs).toBeNull();
|
||||
expect(hasEffect(effects, "play_replayer")).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to alt tab when active tab cannot play", () => {
|
||||
const state: ReplayState = {
|
||||
...twoTabReadyState({
|
||||
phase: "downloading",
|
||||
playbackMode: "buffering",
|
||||
bufferingAtGlobalMs: 2000,
|
||||
autoResumeAfterBuffering: true,
|
||||
activeTabKey: "a",
|
||||
}),
|
||||
streams: makeStreams(
|
||||
{ tabKey: "a", firstMs: 1000, lastMs: 5000 },
|
||||
{ tabKey: "b", firstMs: 1000, lastMs: 5000 },
|
||||
),
|
||||
chunkRangesByTab: makeChunkRanges({
|
||||
a: [[1000, 5000]],
|
||||
b: [[1000, 5000]],
|
||||
}),
|
||||
};
|
||||
// Tab a has no snapshot or replayer, but tab b does
|
||||
state.hasFullSnapshotByTab.delete("a");
|
||||
state.replayerReady.delete("a");
|
||||
const { state: s, effects } = dispatch(state, { type: "DOWNLOAD_COMPLETE", generation: 1 });
|
||||
expect(s.phase).toBe("ready");
|
||||
expect(s.activeTabKey).toBe("b");
|
||||
expect(s.playbackMode).toBe("playing");
|
||||
expect(hasEffect(effects, "ensure_replayer")).toBe(true);
|
||||
expect(hasEffect(effects, "play_replayer")).toBe(true);
|
||||
});
|
||||
|
||||
it("pauses when no tab can play on download complete", () => {
|
||||
const state = twoTabReadyState({
|
||||
phase: "downloading",
|
||||
playbackMode: "buffering",
|
||||
bufferingAtGlobalMs: 2000,
|
||||
autoResumeAfterBuffering: true,
|
||||
activeTabKey: "a",
|
||||
});
|
||||
// No tab has snapshot or replayer
|
||||
state.hasFullSnapshotByTab.clear();
|
||||
state.replayerReady.clear();
|
||||
const { state: s } = dispatch(state, { type: "DOWNLOAD_COMPLETE", generation: 1 });
|
||||
expect(s.phase).toBe("ready");
|
||||
expect(s.playbackMode).toBe("paused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DOWNLOAD_ERROR", () => {
|
||||
@ -1274,6 +1460,393 @@ describe("scenarios", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stall detection tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("stall detection", () => {
|
||||
it("starts tracking when playing with null activeReplayerLocalTimeMs", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
});
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 5000,
|
||||
activeReplayerLocalTimeMs: null,
|
||||
});
|
||||
expect(s.playingWithoutProgressSinceMs).toBe(5000);
|
||||
});
|
||||
|
||||
it("resets tracker when replayer starts responding", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 2000,
|
||||
activeReplayerLocalTimeMs: 500,
|
||||
});
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
});
|
||||
|
||||
it("resets tracker when playback mode is not playing", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 5000,
|
||||
activeReplayerLocalTimeMs: null,
|
||||
});
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
});
|
||||
|
||||
it("does not trigger recovery before threshold", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
playingWithoutProgressSinceMs: 5000,
|
||||
});
|
||||
// 2999ms stall — just under threshold
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 5000 + STALL_THRESHOLD_MS - 1,
|
||||
activeReplayerLocalTimeMs: null,
|
||||
});
|
||||
// Should still be tracking, no recovery
|
||||
expect(s.playingWithoutProgressSinceMs).toBe(5000);
|
||||
expect(s.playbackMode).toBe("playing");
|
||||
});
|
||||
|
||||
it("Strategy A: switches to another ready tab on stall", () => {
|
||||
// Tab a is active but stalled, tab b is ready and in range
|
||||
const state: ReplayState = {
|
||||
...twoTabReadyState(),
|
||||
streams: makeStreams(
|
||||
{ tabKey: "a", firstMs: 1000, lastMs: 5000 },
|
||||
{ tabKey: "b", firstMs: 1000, lastMs: 5000 },
|
||||
),
|
||||
chunkRangesByTab: makeChunkRanges({
|
||||
a: [[1000, 5000]],
|
||||
b: [[1000, 5000]],
|
||||
}),
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
pausedAtGlobalMs: 2000,
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
};
|
||||
const { state: s, effects } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 1000 + STALL_THRESHOLD_MS,
|
||||
activeReplayerLocalTimeMs: null,
|
||||
});
|
||||
expect(s.activeTabKey).toBe("b");
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
expect(hasEffect(effects, "play_replayer")).toBe(true);
|
||||
});
|
||||
|
||||
it("Strategy B: recreates replayer when active tab is ready but broken", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
pausedAtGlobalMs: 2000,
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
// Only tab a is in range at offset 2000, and it's in replayerReady
|
||||
const { state: s, effects } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 1000 + STALL_THRESHOLD_MS,
|
||||
activeReplayerLocalTimeMs: null,
|
||||
});
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
expect(s.replayerReady.has("a")).toBe(false);
|
||||
expect(hasEffect(effects, "recreate_replayer")).toBe(true);
|
||||
const recreateEffects = getEffects(effects, "recreate_replayer");
|
||||
expect((recreateEffects[0] as any).tabKey).toBe("a");
|
||||
});
|
||||
|
||||
it("Strategy C: ensures replayer when tab has snapshot but no replayer", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
pausedAtGlobalMs: 2000,
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
// Tab a has full snapshot but is NOT in replayerReady
|
||||
state.replayerReady.delete("a");
|
||||
const { state: s, effects } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 1000 + STALL_THRESHOLD_MS,
|
||||
activeReplayerLocalTimeMs: null,
|
||||
});
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
expect(hasEffect(effects, "ensure_replayer")).toBe(true);
|
||||
const ensureEffects = getEffects(effects, "ensure_replayer");
|
||||
expect((ensureEffects[0] as any).tabKey).toBe("a");
|
||||
});
|
||||
|
||||
it("Strategy D: switches to any ready tab at a different offset", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
pausedAtGlobalMs: 2000,
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
// Tab a has no snapshot and is not ready, but tab b IS ready
|
||||
state.hasFullSnapshotByTab.delete("a");
|
||||
state.replayerReady.delete("a");
|
||||
const { state: s, effects } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 1000 + STALL_THRESHOLD_MS,
|
||||
activeReplayerLocalTimeMs: null,
|
||||
});
|
||||
expect(s.activeTabKey).toBe("b");
|
||||
expect(s.playbackMode).toBe("playing");
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
expect(hasEffect(effects, "play_replayer")).toBe(true);
|
||||
});
|
||||
|
||||
it("Strategy E: pauses with error when nothing works (download complete)", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
pausedAtGlobalMs: 2000,
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
phase: "ready",
|
||||
});
|
||||
// No tab has snapshot or replayer — nothing can recover
|
||||
state.hasFullSnapshotByTab.clear();
|
||||
state.replayerReady.clear();
|
||||
const { state: s, effects } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 1000 + STALL_THRESHOLD_MS,
|
||||
activeReplayerLocalTimeMs: null,
|
||||
});
|
||||
expect(s.playbackMode).toBe("paused");
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
expect(s.playerError).toContain("stalled");
|
||||
expect(hasEffect(effects, "pause_all")).toBe(true);
|
||||
});
|
||||
|
||||
it("Strategy E: buffers instead of error when still downloading", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
pausedAtGlobalMs: 2000,
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
phase: "downloading",
|
||||
});
|
||||
// No tab has snapshot or replayer — but still downloading
|
||||
state.hasFullSnapshotByTab.clear();
|
||||
state.replayerReady.clear();
|
||||
const { state: s, effects } = dispatch(state, {
|
||||
type: "TICK",
|
||||
nowMs: 1000 + STALL_THRESHOLD_MS,
|
||||
activeReplayerLocalTimeMs: null,
|
||||
});
|
||||
expect(s.playbackMode).toBe("buffering");
|
||||
expect(s.bufferingAtGlobalMs).toBe(2000);
|
||||
expect(s.autoResumeAfterBuffering).toBe(true);
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
expect(s.playerError).toBeNull();
|
||||
expect(hasEffect(effects, "pause_all")).toBe(true);
|
||||
});
|
||||
|
||||
it("TOGGLE_PLAY_PAUSE stays paused with error when no tab can play (download complete)", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
activeTabKey: "a",
|
||||
phase: "ready",
|
||||
});
|
||||
// No tab has snapshot or replayer — nothing can play
|
||||
state.hasFullSnapshotByTab.clear();
|
||||
state.replayerReady.clear();
|
||||
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
|
||||
expect(s.playbackMode).toBe("paused");
|
||||
expect(s.playerError).toContain("Unable to play");
|
||||
});
|
||||
|
||||
it("TOGGLE_PLAY_PAUSE stays paused with error when activeTabKey is null", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
activeTabKey: null,
|
||||
phase: "ready",
|
||||
});
|
||||
state.hasFullSnapshotByTab.clear();
|
||||
state.replayerReady.clear();
|
||||
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
|
||||
expect(s.playbackMode).toBe("paused");
|
||||
expect(s.playerError).toContain("Unable to play");
|
||||
});
|
||||
|
||||
it("TOGGLE_PLAY_PAUSE buffers when no tab can play but still downloading", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
activeTabKey: "a",
|
||||
phase: "downloading",
|
||||
});
|
||||
// No tab has snapshot or replayer yet — data still arriving
|
||||
state.hasFullSnapshotByTab.clear();
|
||||
state.replayerReady.clear();
|
||||
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
|
||||
expect(s.playbackMode).toBe("buffering");
|
||||
expect(s.autoResumeAfterBuffering).toBe(true);
|
||||
expect(s.playerError).toBeNull();
|
||||
});
|
||||
|
||||
it("resets tracker on TOGGLE_PLAY_PAUSE (pause)", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 2000 });
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
});
|
||||
|
||||
it("resets tracker on TOGGLE_PLAY_PAUSE (play)", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 2000 });
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
});
|
||||
|
||||
it("resets tracker on SEEK", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
const { state: s } = dispatch(state, { type: "SEEK", globalOffsetMs: 2000, nowMs: 2000 });
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
});
|
||||
|
||||
it("resets tracker on SELECT_TAB", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 2000 });
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
});
|
||||
|
||||
it("resets tracker on REPLAYER_READY", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
playingWithoutProgressSinceMs: 1000,
|
||||
});
|
||||
state.replayerReady.delete("a");
|
||||
const { state: s } = dispatch(state, { type: "REPLAYER_READY", generation: 1, tabKey: "a" });
|
||||
expect(s.playingWithoutProgressSinceMs).toBeNull();
|
||||
});
|
||||
|
||||
it("initializes tracker as null", () => {
|
||||
const state = createInitialState();
|
||||
expect(state.playingWithoutProgressSinceMs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// playerError clearing tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("playerError clearing", () => {
|
||||
it("TOGGLE_PLAY_PAUSE (play) clears playerError", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "paused",
|
||||
playerError: "Playback stalled: unable to recover.",
|
||||
});
|
||||
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
|
||||
expect(s.playbackMode).toBe("playing");
|
||||
expect(s.playerError).toBeNull();
|
||||
});
|
||||
|
||||
it("TOGGLE_PLAY_PAUSE (pause) clears playerError", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
playerError: "Some error",
|
||||
});
|
||||
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
|
||||
expect(s.playbackMode).toBe("paused");
|
||||
expect(s.playerError).toBeNull();
|
||||
});
|
||||
|
||||
it("SEEK clears playerError", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
playerError: "Playback stalled: unable to recover.",
|
||||
});
|
||||
const { state: s } = dispatch(state, { type: "SEEK", globalOffsetMs: 2000, nowMs: 1000 });
|
||||
expect(s.playerError).toBeNull();
|
||||
});
|
||||
|
||||
it("SELECT_TAB clears playerError", () => {
|
||||
const state = twoTabReadyState({
|
||||
playbackMode: "playing",
|
||||
activeTabKey: "a",
|
||||
playerError: "Playback stalled: unable to recover.",
|
||||
});
|
||||
const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 1000 });
|
||||
expect(s.playerError).toBeNull();
|
||||
});
|
||||
|
||||
it("CHUNK_LOADED with hasFullSnapshot on active tab clears playerError", () => {
|
||||
const state = twoTabReadyState({
|
||||
activeTabKey: "a",
|
||||
playerError: "Unable to play: recording data may be incomplete.",
|
||||
});
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "CHUNK_LOADED",
|
||||
generation: 1,
|
||||
tabKey: "a",
|
||||
hasFullSnapshot: true,
|
||||
loadedDurationMs: 3500,
|
||||
hadEventsBeforeThisChunk: true,
|
||||
});
|
||||
expect(s.playerError).toBeNull();
|
||||
});
|
||||
|
||||
it("CHUNK_LOADED on non-active tab does NOT clear playerError", () => {
|
||||
const state = twoTabReadyState({
|
||||
activeTabKey: "a",
|
||||
playerError: "Unable to play: recording data may be incomplete.",
|
||||
});
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "CHUNK_LOADED",
|
||||
generation: 1,
|
||||
tabKey: "b",
|
||||
hasFullSnapshot: true,
|
||||
loadedDurationMs: 3500,
|
||||
hadEventsBeforeThisChunk: true,
|
||||
});
|
||||
expect(s.playerError).toBe("Unable to play: recording data may be incomplete.");
|
||||
});
|
||||
|
||||
it("CHUNK_LOADED without hasFullSnapshot on active tab does NOT clear playerError", () => {
|
||||
const state = twoTabReadyState({
|
||||
activeTabKey: "a",
|
||||
playerError: "Unable to play: recording data may be incomplete.",
|
||||
});
|
||||
const { state: s } = dispatch(state, {
|
||||
type: "CHUNK_LOADED",
|
||||
generation: 1,
|
||||
tabKey: "a",
|
||||
hasFullSnapshot: false,
|
||||
loadedDurationMs: 3500,
|
||||
hadEventsBeforeThisChunk: true,
|
||||
});
|
||||
expect(s.playerError).toBe("Unable to play: recording data may be incomplete.");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invariant tests (fuzz random action sequences)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -17,6 +17,10 @@ import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
|
||||
export const ALLOWED_PLAYER_SPEEDS = new Set([0.5, 1, 2, 4]);
|
||||
|
||||
/** How long (wall-clock ms) the player can be in "playing" mode without the
|
||||
* replayer reporting progress before we attempt automatic recovery. */
|
||||
export const STALL_THRESHOLD_MS = 3000;
|
||||
|
||||
export const DEFAULT_REPLAY_SETTINGS: ReplaySettings = {
|
||||
playerSpeed: 1,
|
||||
skipInactivity: true,
|
||||
@ -93,6 +97,10 @@ export type ReplayState = {
|
||||
* position because `addEvent` didn't extend the playable range. */
|
||||
prematureFinishRetryLocalMs: number | null,
|
||||
|
||||
/** Wall-clock time when we first noticed "playing" mode but the replayer
|
||||
* hadn't reported any progress. Used for stall detection. */
|
||||
playingWithoutProgressSinceMs: number | null,
|
||||
|
||||
downloadError: string | null,
|
||||
playerError: string | null,
|
||||
};
|
||||
@ -194,6 +202,7 @@ export function createInitialState(settings?: ReplaySettings): ReplayState {
|
||||
bufferingAtGlobalMs: null,
|
||||
gapFastForward: null,
|
||||
prematureFinishRetryLocalMs: null,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
downloadError: null,
|
||||
playerError: null,
|
||||
};
|
||||
@ -378,22 +387,36 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
if (isStaleGeneration(state, action.generation)) return { state, effects: [] };
|
||||
|
||||
const effects: ReplayEffect[] = [];
|
||||
let newPlaybackMode = state.playbackMode;
|
||||
let newPlaybackMode: PlaybackMode = state.playbackMode === "buffering" ? "paused" : state.playbackMode;
|
||||
let newActiveTabKey = state.activeTabKey;
|
||||
|
||||
// Safety net: if buffering when download finishes, resume
|
||||
// Safety net: if buffering when download finishes, try to resume
|
||||
if (state.bufferingAtGlobalMs !== null && state.autoResumeAfterBuffering) {
|
||||
const seekTo = state.bufferingAtGlobalMs;
|
||||
newPlaybackMode = "playing";
|
||||
effects.push(...playEffectsForAllTabs({ ...state, playbackMode: "playing", activeTabKey: state.activeTabKey }, seekTo));
|
||||
let resumeTabKey = state.activeTabKey;
|
||||
|
||||
// Verify the active tab can actually play
|
||||
if (resumeTabKey && !state.replayerReady.has(resumeTabKey) && !state.hasFullSnapshotByTab.has(resumeTabKey)) {
|
||||
resumeTabKey = findBestTabAtGlobalOffset(state, seekTo);
|
||||
}
|
||||
|
||||
if (resumeTabKey && (state.replayerReady.has(resumeTabKey) || state.hasFullSnapshotByTab.has(resumeTabKey))) {
|
||||
newPlaybackMode = "playing";
|
||||
newActiveTabKey = resumeTabKey;
|
||||
if (resumeTabKey !== state.activeTabKey) {
|
||||
effects.push({ type: "ensure_replayer", tabKey: resumeTabKey, generation: state.generation });
|
||||
}
|
||||
effects.push(...playEffectsForAllTabs({ ...state, playbackMode: "playing", activeTabKey: resumeTabKey }, seekTo));
|
||||
}
|
||||
// else: newPlaybackMode stays "paused" — no tab can play
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
phase: "ready",
|
||||
playbackMode: state.bufferingAtGlobalMs !== null && state.autoResumeAfterBuffering
|
||||
? "playing"
|
||||
: (state.playbackMode === "buffering" ? "paused" : state.playbackMode),
|
||||
activeTabKey: newActiveTabKey,
|
||||
playbackMode: newPlaybackMode,
|
||||
bufferingAtGlobalMs: null,
|
||||
autoResumeAfterBuffering: false,
|
||||
},
|
||||
@ -436,6 +459,19 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
effects.push({ type: "ensure_replayer", tabKey: action.tabKey, generation: action.generation });
|
||||
}
|
||||
|
||||
// If the active tab has no FullSnapshot but this tab just got one, switch.
|
||||
// This ensures the component renders a container for the playable tab.
|
||||
let newActiveTabKey = state.activeTabKey;
|
||||
if (
|
||||
action.hasFullSnapshot
|
||||
&& !state.hasFullSnapshotByTab.has(action.tabKey)
|
||||
&& state.activeTabKey !== null
|
||||
&& state.activeTabKey !== action.tabKey
|
||||
&& !newHasFullSnapshot.has(state.activeTabKey)
|
||||
) {
|
||||
newActiveTabKey = action.tabKey;
|
||||
}
|
||||
|
||||
// Check if buffering can be resolved by new data
|
||||
let newPlaybackMode = state.playbackMode;
|
||||
let newBufferingAtGlobalMs = state.bufferingAtGlobalMs;
|
||||
@ -443,7 +479,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
let newPausedAtGlobalMs = state.pausedAtGlobalMs;
|
||||
|
||||
if (
|
||||
state.activeTabKey === action.tabKey
|
||||
newActiveTabKey === action.tabKey
|
||||
&& state.bufferingAtGlobalMs !== null
|
||||
) {
|
||||
const stream = getStreamInfo(state, action.tabKey);
|
||||
@ -463,7 +499,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
newPlaybackMode = "playing";
|
||||
newPausedAtGlobalMs = seekTo;
|
||||
effects.push(...playEffectsForAllTabs(
|
||||
{ ...state, playbackMode: "playing", activeTabKey: state.activeTabKey },
|
||||
{ ...state, playbackMode: "playing", activeTabKey: newActiveTabKey },
|
||||
seekTo,
|
||||
));
|
||||
} else {
|
||||
@ -473,9 +509,13 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
}
|
||||
}
|
||||
|
||||
// Clear playerError when the active tab receives a FullSnapshot
|
||||
const clearPlayerError = action.hasFullSnapshot && action.tabKey === newActiveTabKey;
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
activeTabKey: newActiveTabKey,
|
||||
hasFullSnapshotByTab: newHasFullSnapshot,
|
||||
loadedDurationByTabMs: newLoadedDuration,
|
||||
tabsWithEvents: newTabsWithEvents,
|
||||
@ -483,6 +523,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
bufferingAtGlobalMs: newBufferingAtGlobalMs,
|
||||
autoResumeAfterBuffering: newAutoResumeAfterBuffering,
|
||||
pausedAtGlobalMs: newPausedAtGlobalMs,
|
||||
...(clearPlayerError ? { playerError: null } : {}),
|
||||
},
|
||||
effects,
|
||||
};
|
||||
@ -495,13 +536,28 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
newReplayerReady.add(action.tabKey);
|
||||
|
||||
const isActiveTab = state.activeTabKey === action.tabKey;
|
||||
const shouldAutoPlay = !state.autoPlayTriggered && isActiveTab;
|
||||
const shouldPlay = isActiveTab && (shouldAutoPlay || (state.playbackMode === "playing"));
|
||||
|
||||
// Auto-play fallback: if auto-play hasn't triggered yet and the active
|
||||
// tab is stuck (no full snapshot → replayer can never be created), switch
|
||||
// the active tab to this newly-ready tab so auto-play can proceed.
|
||||
const activeTabStuck = !isActiveTab
|
||||
&& !state.autoPlayTriggered
|
||||
&& state.activeTabKey !== null
|
||||
&& !state.hasFullSnapshotByTab.has(state.activeTabKey);
|
||||
const effectiveIsActiveTab = isActiveTab || activeTabStuck;
|
||||
|
||||
const shouldAutoPlay = !state.autoPlayTriggered && effectiveIsActiveTab;
|
||||
const shouldPlay = effectiveIsActiveTab && (shouldAutoPlay || (state.playbackMode === "playing"));
|
||||
|
||||
const newActiveTabKey = activeTabStuck ? action.tabKey : state.activeTabKey;
|
||||
|
||||
const effects: ReplayEffect[] = [];
|
||||
const stream = getStreamInfo(state, action.tabKey);
|
||||
const streamStartTs = stream?.firstEventAtMs ?? state.globalStartTs;
|
||||
const desiredLocal = globalOffsetToLocalOffset(state.globalStartTs, streamStartTs, state.pausedAtGlobalMs);
|
||||
const targetGlobalMs = activeTabStuck
|
||||
? localOffsetToGlobalOffset(state.globalStartTs, streamStartTs, 0)
|
||||
: state.pausedAtGlobalMs;
|
||||
const desiredLocal = globalOffsetToLocalOffset(state.globalStartTs, streamStartTs, targetGlobalMs);
|
||||
|
||||
if (shouldPlay) {
|
||||
effects.push({ type: "play_replayer", tabKey: action.tabKey, localOffsetMs: desiredLocal });
|
||||
@ -512,11 +568,14 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
activeTabKey: newActiveTabKey,
|
||||
replayerReady: newReplayerReady,
|
||||
autoPlayTriggered: state.autoPlayTriggered || shouldAutoPlay,
|
||||
playbackMode: shouldAutoPlay && state.playbackMode !== "buffering"
|
||||
? "playing"
|
||||
: state.playbackMode,
|
||||
pausedAtGlobalMs: activeTabStuck ? targetGlobalMs : state.pausedAtGlobalMs,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
},
|
||||
effects,
|
||||
};
|
||||
@ -555,13 +614,14 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
...state,
|
||||
replayerReady: newReplayerReady,
|
||||
prematureFinishRetryLocalMs: null,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
},
|
||||
effects: [{ type: "recreate_replayer", tabKey: action.tabKey, generation: action.generation }],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, prematureFinishRetryLocalMs: localTime },
|
||||
state: { ...state, prematureFinishRetryLocalMs: localTime, playingWithoutProgressSinceMs: null },
|
||||
effects: [{ type: "play_replayer", tabKey: action.tabKey, localOffsetMs: localTime }],
|
||||
};
|
||||
}
|
||||
@ -586,6 +646,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
pausedAtGlobalMs: globalOffset,
|
||||
bufferingAtGlobalMs: globalOffset,
|
||||
autoResumeAfterBuffering: true,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
},
|
||||
effects: [
|
||||
{ type: "schedule_buffer_poll", generation: action.generation, tabKey: action.tabKey, localTimeMs: localTime, delayMs: 500 },
|
||||
@ -622,6 +683,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
bufferingAtGlobalMs: null,
|
||||
autoResumeAfterBuffering: false,
|
||||
suppressAutoFollowUntilWallMs: action.nowMs + 400,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
},
|
||||
effects,
|
||||
};
|
||||
@ -654,6 +716,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
playbackMode: "gap_fast_forward",
|
||||
gapFastForward: gff,
|
||||
pausedAtGlobalMs: globalOffset,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
},
|
||||
effects: [],
|
||||
};
|
||||
@ -667,6 +730,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
pausedAtGlobalMs: globalOffset,
|
||||
bufferingAtGlobalMs: globalOffset,
|
||||
autoResumeAfterBuffering: true,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
},
|
||||
effects: [],
|
||||
};
|
||||
@ -681,6 +745,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
currentGlobalTimeMsForUi: state.globalTotalMs,
|
||||
gapFastForward: null,
|
||||
bufferingAtGlobalMs: null,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
},
|
||||
effects: [{ type: "pause_all" }],
|
||||
};
|
||||
@ -699,43 +764,109 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
gapFastForward: null,
|
||||
bufferingAtGlobalMs: null,
|
||||
autoResumeAfterBuffering: false,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
playerError: null,
|
||||
},
|
||||
effects: [{ type: "pause_all" }],
|
||||
};
|
||||
}
|
||||
|
||||
// Play
|
||||
const target = state.pausedAtGlobalMs;
|
||||
let target = state.pausedAtGlobalMs;
|
||||
let playActiveTabKey = state.activeTabKey;
|
||||
|
||||
// If active tab has no replayer and can't get one, switch to a tab that can play
|
||||
if (playActiveTabKey && !state.replayerReady.has(playActiveTabKey) && !state.hasFullSnapshotByTab.has(playActiveTabKey)) {
|
||||
const altTab = findBestTabAtGlobalOffset(state, target);
|
||||
if (altTab) {
|
||||
playActiveTabKey = altTab;
|
||||
} else {
|
||||
// Find any ready tab at its start time
|
||||
for (const s of state.streams) {
|
||||
if (state.replayerReady.has(s.tabKey)) {
|
||||
playActiveTabKey = s.tabKey;
|
||||
target = localOffsetToGlobalOffset(state.globalStartTs, s.firstEventAtMs, 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: if no tab can play, either buffer (still downloading) or error
|
||||
if (
|
||||
!playActiveTabKey
|
||||
|| (!state.replayerReady.has(playActiveTabKey) && !state.hasFullSnapshotByTab.has(playActiveTabKey))
|
||||
) {
|
||||
if (state.phase === "downloading" && playActiveTabKey) {
|
||||
// Data may still arrive — enter buffering mode
|
||||
const bufferStream = getStreamInfo(state, playActiveTabKey);
|
||||
const bufferLocalMs = bufferStream ? globalOffsetToLocalOffset(state.globalStartTs, bufferStream.firstEventAtMs, target) : 0;
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
activeTabKey: playActiveTabKey,
|
||||
pausedAtGlobalMs: target,
|
||||
playbackMode: "buffering",
|
||||
bufferingAtGlobalMs: target,
|
||||
autoResumeAfterBuffering: true,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
playerError: null,
|
||||
},
|
||||
effects: [
|
||||
{ type: "schedule_buffer_poll", generation: state.generation, tabKey: playActiveTabKey, localTimeMs: bufferLocalMs, delayMs: 500 },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
playbackMode: "paused",
|
||||
playerError: "Unable to play: recording data may be incomplete. Try reloading.",
|
||||
playingWithoutProgressSinceMs: null,
|
||||
},
|
||||
effects: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if active tab needs buffering
|
||||
if (state.phase === "downloading" && state.activeTabKey) {
|
||||
const stream = getStreamInfo(state, state.activeTabKey);
|
||||
if (state.phase === "downloading" && playActiveTabKey) {
|
||||
const stream = getStreamInfo(state, playActiveTabKey);
|
||||
if (stream) {
|
||||
const localTarget = globalOffsetToLocalOffset(state.globalStartTs, stream.firstEventAtMs, target);
|
||||
const loaded = state.loadedDurationByTabMs.get(state.activeTabKey) ?? 0;
|
||||
const loaded = state.loadedDurationByTabMs.get(playActiveTabKey) ?? 0;
|
||||
if (localTarget > loaded) {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
activeTabKey: playActiveTabKey,
|
||||
pausedAtGlobalMs: target,
|
||||
playbackMode: "buffering",
|
||||
bufferingAtGlobalMs: target,
|
||||
autoResumeAfterBuffering: true,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
playerError: null,
|
||||
},
|
||||
effects: [],
|
||||
effects: [
|
||||
{ type: "schedule_buffer_poll", generation: state.generation, tabKey: playActiveTabKey, localTimeMs: localTarget, delayMs: 500 },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stateForPlay = { ...state, activeTabKey: playActiveTabKey };
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
...stateForPlay,
|
||||
playbackMode: "playing",
|
||||
pausedAtGlobalMs: target,
|
||||
bufferingAtGlobalMs: null,
|
||||
gapFastForward: null,
|
||||
suppressAutoFollowUntilWallMs: action.nowMs + 400,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
playerError: null,
|
||||
},
|
||||
effects: playEffectsForAllTabs(state, target),
|
||||
effects: playEffectsForAllTabs(stateForPlay, target),
|
||||
};
|
||||
}
|
||||
|
||||
@ -770,6 +901,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
bufferingAtGlobalMs: action.globalOffsetMs,
|
||||
autoResumeAfterBuffering: true,
|
||||
prematureFinishRetryLocalMs: null,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
playerError: null,
|
||||
},
|
||||
effects,
|
||||
};
|
||||
@ -790,6 +923,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
currentGlobalTimeMsForUi: action.globalOffsetMs,
|
||||
suppressAutoFollowUntilWallMs: action.nowMs + 400,
|
||||
prematureFinishRetryLocalMs: null,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
playerError: null,
|
||||
},
|
||||
effects,
|
||||
};
|
||||
@ -817,6 +952,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
autoResumeAfterBuffering: true,
|
||||
suppressAutoFollowUntilWallMs: action.nowMs + 5000,
|
||||
prematureFinishRetryLocalMs: null,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
playerError: null,
|
||||
},
|
||||
effects,
|
||||
};
|
||||
@ -840,6 +977,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
autoResumeAfterBuffering: false,
|
||||
suppressAutoFollowUntilWallMs: action.nowMs + 5000,
|
||||
prematureFinishRetryLocalMs: null,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
playerError: null,
|
||||
},
|
||||
effects,
|
||||
};
|
||||
@ -944,6 +1083,101 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Stall detection -----
|
||||
// Track when the player is in "playing" mode but the replayer hasn't
|
||||
// reported any progress (activeReplayerLocalTimeMs is null).
|
||||
if (newState.playbackMode === "playing" && action.activeReplayerLocalTimeMs !== null) {
|
||||
// Replayer is responding — clear tracker
|
||||
newState = { ...newState, playingWithoutProgressSinceMs: null };
|
||||
} else if (newState.playbackMode !== "playing") {
|
||||
// Not playing — clear tracker
|
||||
newState = { ...newState, playingWithoutProgressSinceMs: null };
|
||||
} else if (action.activeReplayerLocalTimeMs === null) {
|
||||
if (newState.playingWithoutProgressSinceMs === null) {
|
||||
// Start timing the stall
|
||||
newState = { ...newState, playingWithoutProgressSinceMs: action.nowMs };
|
||||
} else if (action.nowMs - newState.playingWithoutProgressSinceMs >= STALL_THRESHOLD_MS) {
|
||||
// Stall detected — attempt recovery
|
||||
const stallGlobalOffset = newState.pausedAtGlobalMs;
|
||||
|
||||
// Strategy A: Switch to another tab that IS ready
|
||||
const altTab = findBestTabAtGlobalOffset(newState, stallGlobalOffset, newState.activeTabKey ?? undefined);
|
||||
if (altTab && newState.replayerReady.has(altTab)) {
|
||||
newState = {
|
||||
...newState,
|
||||
activeTabKey: altTab,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
suppressAutoFollowUntilWallMs: action.nowMs + 400,
|
||||
};
|
||||
effects.push(
|
||||
...playEffectsForAllTabs(newState, stallGlobalOffset),
|
||||
);
|
||||
return { state: newState, effects };
|
||||
}
|
||||
|
||||
// Strategy B: Active tab IS in replayerReady (but broken) — recreate
|
||||
const activeKeyB = newState.activeTabKey;
|
||||
if (activeKeyB && newState.replayerReady.has(activeKeyB)) {
|
||||
const newReplayerReady = new Set(newState.replayerReady);
|
||||
newReplayerReady.delete(activeKeyB);
|
||||
newState = {
|
||||
...newState,
|
||||
replayerReady: newReplayerReady,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
};
|
||||
effects.push({ type: "recreate_replayer", tabKey: activeKeyB, generation: newState.generation });
|
||||
return { state: newState, effects };
|
||||
}
|
||||
|
||||
// Strategy C: Tab has full snapshot but no replayer — ensure it
|
||||
const activeKeyC = newState.activeTabKey;
|
||||
if (activeKeyC && newState.hasFullSnapshotByTab.has(activeKeyC)) {
|
||||
newState = { ...newState, playingWithoutProgressSinceMs: null };
|
||||
effects.push({ type: "ensure_replayer", tabKey: activeKeyC, generation: newState.generation });
|
||||
return { state: newState, effects };
|
||||
}
|
||||
|
||||
// Strategy D: Switch to ANY ready tab (even at a different offset)
|
||||
for (const s of newState.streams) {
|
||||
if (s.tabKey === newState.activeTabKey) continue;
|
||||
if (!newState.replayerReady.has(s.tabKey)) continue;
|
||||
const altGlobalMs = localOffsetToGlobalOffset(newState.globalStartTs, s.firstEventAtMs, 0);
|
||||
newState = {
|
||||
...newState,
|
||||
activeTabKey: s.tabKey,
|
||||
pausedAtGlobalMs: altGlobalMs,
|
||||
currentGlobalTimeMsForUi: altGlobalMs,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
suppressAutoFollowUntilWallMs: action.nowMs + 400,
|
||||
};
|
||||
effects.push(...playEffectsForAllTabs(newState, altGlobalMs));
|
||||
return { state: newState, effects };
|
||||
}
|
||||
|
||||
// Strategy E: Nothing works
|
||||
if (state.phase === "downloading") {
|
||||
// Still downloading — enter buffering, FullSnapshot may arrive
|
||||
newState = {
|
||||
...newState,
|
||||
playbackMode: "buffering",
|
||||
bufferingAtGlobalMs: stallGlobalOffset,
|
||||
autoResumeAfterBuffering: true,
|
||||
playingWithoutProgressSinceMs: null,
|
||||
};
|
||||
effects.push({ type: "pause_all" });
|
||||
return { state: newState, effects };
|
||||
}
|
||||
newState = {
|
||||
...newState,
|
||||
playbackMode: "paused",
|
||||
playingWithoutProgressSinceMs: null,
|
||||
playerError: "Playback stalled: unable to recover. Try seeking or switching tabs.",
|
||||
};
|
||||
effects.push({ type: "pause_all" });
|
||||
return { state: newState, effects };
|
||||
}
|
||||
}
|
||||
|
||||
return { state: newState, effects };
|
||||
}
|
||||
|
||||
@ -961,6 +1195,37 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
|
||||
|
||||
if (loaded > localTarget + 2000 || state.phase !== "downloading") {
|
||||
const seekTo = state.bufferingAtGlobalMs;
|
||||
|
||||
// Verify the active tab can actually play before resuming
|
||||
if (!state.replayerReady.has(action.tabKey) && !state.hasFullSnapshotByTab.has(action.tabKey)) {
|
||||
const altTab = findBestTabAtGlobalOffset(state, seekTo);
|
||||
if (altTab) {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
activeTabKey: altTab,
|
||||
playbackMode: "playing",
|
||||
bufferingAtGlobalMs: null,
|
||||
autoResumeAfterBuffering: false,
|
||||
},
|
||||
effects: [
|
||||
{ type: "ensure_replayer", tabKey: altTab, generation: action.generation },
|
||||
...playEffectsForAllTabs({ ...state, activeTabKey: altTab }, seekTo),
|
||||
],
|
||||
};
|
||||
}
|
||||
// No tab can play — fall back to paused
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
playbackMode: "paused",
|
||||
bufferingAtGlobalMs: null,
|
||||
autoResumeAfterBuffering: false,
|
||||
},
|
||||
effects: [{ type: "pause_all" }],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user