|
1 | 1 | import { describe, expect, it, vi } from "vitest"; |
2 | | -import { wrapTimeline } from "./playbackAdapter"; |
3 | | -import type { TimelineLike } from "./playbackTypes"; |
| 2 | +import { createStaticSeekPlaybackAdapter, wrapTimeline } from "./playbackAdapter"; |
| 3 | +import type { |
| 4 | + RuntimePlaybackAdapter, |
| 5 | + StaticSeekPlaybackClock, |
| 6 | + TimelineLike, |
| 7 | +} from "./playbackTypes"; |
4 | 8 |
|
5 | 9 | describe("wrapTimeline seek keepPlaying option (#834)", () => { |
6 | 10 | function mockTimeline(): TimelineLike & { |
@@ -48,3 +52,162 @@ describe("wrapTimeline seek keepPlaying option (#834)", () => { |
48 | 52 | expect(tl.seek).toHaveBeenCalledWith(5); |
49 | 53 | }); |
50 | 54 | }); |
| 55 | + |
| 56 | +describe("createStaticSeekPlaybackAdapter seek keepPlaying option", () => { |
| 57 | + type StaticSeekPlayer = Pick<RuntimePlaybackAdapter, "getTime"> & |
| 58 | + Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>; |
| 59 | + |
| 60 | + function makeFakeClock(): StaticSeekPlaybackClock & { |
| 61 | + runNextFrame: () => boolean; |
| 62 | + cancelled: number[]; |
| 63 | + scheduled: number; |
| 64 | + setNow: (ms: number) => void; |
| 65 | + } { |
| 66 | + let now = 0; |
| 67 | + let nextHandle = 0; |
| 68 | + const pending = new Map<number, FrameRequestCallback>(); |
| 69 | + const cancelled: number[] = []; |
| 70 | + let scheduled = 0; |
| 71 | + return { |
| 72 | + now: () => now, |
| 73 | + requestAnimationFrame: (cb) => { |
| 74 | + nextHandle += 1; |
| 75 | + pending.set(nextHandle, cb); |
| 76 | + scheduled += 1; |
| 77 | + return nextHandle; |
| 78 | + }, |
| 79 | + cancelAnimationFrame: (handle) => { |
| 80 | + if (pending.delete(handle)) cancelled.push(handle); |
| 81 | + }, |
| 82 | + runNextFrame: () => { |
| 83 | + const next = pending.entries().next(); |
| 84 | + if (next.done) return false; |
| 85 | + const [handle, cb] = next.value; |
| 86 | + pending.delete(handle); |
| 87 | + cb(now); |
| 88 | + return true; |
| 89 | + }, |
| 90 | + cancelled, |
| 91 | + get scheduled() { |
| 92 | + return scheduled; |
| 93 | + }, |
| 94 | + setNow: (ms) => { |
| 95 | + now = ms; |
| 96 | + }, |
| 97 | + }; |
| 98 | + } |
| 99 | + |
| 100 | + function makePlayer(): StaticSeekPlayer & { |
| 101 | + renderSeek: ReturnType<typeof vi.fn>; |
| 102 | + } { |
| 103 | + return { |
| 104 | + getTime: () => 0, |
| 105 | + renderSeek: vi.fn(), |
| 106 | + }; |
| 107 | + } |
| 108 | + |
| 109 | + it("default seek stops the RAF ticker so the adapter reports paused", () => { |
| 110 | + const clock = makeFakeClock(); |
| 111 | + const player = makePlayer(); |
| 112 | + const adapter = createStaticSeekPlaybackAdapter(player, 10, clock); |
| 113 | + |
| 114 | + adapter.play(); |
| 115 | + expect(adapter.isPlaying()).toBe(true); |
| 116 | + |
| 117 | + adapter.seek(5); |
| 118 | + |
| 119 | + expect(adapter.isPlaying()).toBe(false); |
| 120 | + expect(adapter.getTime()).toBe(5); |
| 121 | + expect(player.renderSeek).toHaveBeenLastCalledWith(5); |
| 122 | + expect(clock.cancelled.length).toBeGreaterThan(0); |
| 123 | + }); |
| 124 | + |
| 125 | + it("default seek prevents the ticker from advancing further", () => { |
| 126 | + const clock = makeFakeClock(); |
| 127 | + const player = makePlayer(); |
| 128 | + const adapter = createStaticSeekPlaybackAdapter(player, 10, clock); |
| 129 | + |
| 130 | + adapter.play(); |
| 131 | + player.renderSeek.mockClear(); |
| 132 | + |
| 133 | + adapter.seek(5); |
| 134 | + |
| 135 | + // Any frame the RAF callback already had queued before cancel should be a no-op. |
| 136 | + clock.setNow(1000); |
| 137 | + clock.runNextFrame(); |
| 138 | + expect(player.renderSeek).toHaveBeenCalledTimes(1); // only the seek itself |
| 139 | + expect(player.renderSeek).toHaveBeenLastCalledWith(5); |
| 140 | + expect(adapter.getTime()).toBe(5); |
| 141 | + }); |
| 142 | + |
| 143 | + it("seek with { keepPlaying: true } preserves playback and rebases the ticker", () => { |
| 144 | + const clock = makeFakeClock(); |
| 145 | + const player = makePlayer(); |
| 146 | + const adapter = createStaticSeekPlaybackAdapter(player, 10, clock); |
| 147 | + |
| 148 | + adapter.play(); |
| 149 | + clock.setNow(500); |
| 150 | + expect(adapter.isPlaying()).toBe(true); |
| 151 | + |
| 152 | + adapter.seek(3, { keepPlaying: true }); |
| 153 | + |
| 154 | + expect(adapter.isPlaying()).toBe(true); |
| 155 | + expect(adapter.getTime()).toBe(3); |
| 156 | + |
| 157 | + // Advance 1s of wall-clock time. With playStartTime rebased to 3 and |
| 158 | + // playStartNow rebased to 500, the next tick should render around t=4. |
| 159 | + clock.setNow(1500); |
| 160 | + clock.runNextFrame(); |
| 161 | + expect(player.renderSeek).toHaveBeenLastCalledWith(4); |
| 162 | + }); |
| 163 | + |
| 164 | + it("seek with { keepPlaying: false } pauses (matches default)", () => { |
| 165 | + const clock = makeFakeClock(); |
| 166 | + const player = makePlayer(); |
| 167 | + const adapter = createStaticSeekPlaybackAdapter(player, 10, clock); |
| 168 | + |
| 169 | + adapter.play(); |
| 170 | + adapter.seek(5, { keepPlaying: false }); |
| 171 | + |
| 172 | + expect(adapter.isPlaying()).toBe(false); |
| 173 | + expect(player.renderSeek).toHaveBeenLastCalledWith(5); |
| 174 | + }); |
| 175 | + |
| 176 | + it("seek with { keepPlaying: true } does not force playback when adapter is paused", () => { |
| 177 | + const clock = makeFakeClock(); |
| 178 | + const player = makePlayer(); |
| 179 | + const adapter = createStaticSeekPlaybackAdapter(player, 10, clock); |
| 180 | + |
| 181 | + adapter.seek(2, { keepPlaying: true }); |
| 182 | + |
| 183 | + expect(adapter.isPlaying()).toBe(false); |
| 184 | + expect(adapter.getTime()).toBe(2); |
| 185 | + expect(player.renderSeek).toHaveBeenLastCalledWith(2); |
| 186 | + }); |
| 187 | + |
| 188 | + it("seek without options stays back-compatible with the previous signature", () => { |
| 189 | + const clock = makeFakeClock(); |
| 190 | + const player = makePlayer(); |
| 191 | + const adapter = createStaticSeekPlaybackAdapter(player, 10, clock); |
| 192 | + |
| 193 | + // Caller written before the options parameter existed. |
| 194 | + adapter.seek(4); |
| 195 | + |
| 196 | + expect(player.renderSeek).toHaveBeenLastCalledWith(4); |
| 197 | + expect(adapter.getTime()).toBe(4); |
| 198 | + expect(adapter.isPlaying()).toBe(false); |
| 199 | + }); |
| 200 | + |
| 201 | + it("default seek clamps to duration and still pauses", () => { |
| 202 | + const clock = makeFakeClock(); |
| 203 | + const player = makePlayer(); |
| 204 | + const adapter = createStaticSeekPlaybackAdapter(player, 10, clock); |
| 205 | + |
| 206 | + adapter.play(); |
| 207 | + adapter.seek(99); |
| 208 | + |
| 209 | + expect(adapter.getTime()).toBe(10); |
| 210 | + expect(player.renderSeek).toHaveBeenLastCalledWith(10); |
| 211 | + expect(adapter.isPlaying()).toBe(false); |
| 212 | + }); |
| 213 | +}); |
0 commit comments