Skip to content

Commit 84060fb

Browse files
JohnMcLearCopilot
andauthored
feat: add timeslider playback speed setting (#7541)
Add a core timeslider playback speed setting with an original-speed default, a realtime mode that uses revision timestamps, and frontend coverage for the new behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7ec581a commit 84060fb

5 files changed

Lines changed: 169 additions & 2 deletions

File tree

src/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@
171171
"timeslider.exportCurrent": "Export current version as:",
172172
"timeslider.version": "Version {{version}}",
173173
"timeslider.saved": "Saved {{month}} {{day}}, {{year}}",
174+
"timeslider.settings.playbackSpeed": "Playback speed:",
175+
"timeslider.settings.playbackSpeed.original": "Original speed",
176+
"timeslider.settings.playbackSpeed.realtime": "Realtime",
177+
"timeslider.settings.playbackSpeed.200ms": "200 ms",
178+
"timeslider.settings.playbackSpeed.500ms": "500 ms",
179+
"timeslider.settings.playbackSpeed.1000ms": "1000 ms",
174180

175181
"timeslider.playPause": "Playback / Pause Pad Contents",
176182
"timeslider.backRevision":"Go back a revision in this Pad",

src/static/js/broadcast_slider.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,26 @@ const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
4343
const slidercallbacks = [];
4444
const savedRevisions = [];
4545
let sliderPlaying = false;
46+
let playbackSpeed = '100';
47+
48+
const getPlaybackDelay = () => {
49+
if (playbackSpeed !== 'realtime') return Number(playbackSpeed);
50+
const path = window.revisionInfo.getPath(getSliderPosition(), getSliderPosition() + 1);
51+
if (path.status !== 'complete' || path.times.length === 0) return null;
52+
const delay = Number(path.times[0]);
53+
return Number.isFinite(delay) ? Math.max(0, delay) : null;
54+
};
55+
56+
const scheduleNextPlaybackStep = () => {
57+
const delay = getPlaybackDelay();
58+
if (delay == null) {
59+
setTimeout(() => {
60+
if (sliderPlaying) scheduleNextPlaybackStep();
61+
}, 100);
62+
return;
63+
}
64+
setTimeout(playButtonUpdater, delay);
65+
};
4666

4767
const _callSliderCallbacks = (newval) => {
4868
sliderPos = newval;
@@ -180,7 +200,11 @@ const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
180200
}
181201
setSliderPosition(getSliderPosition() + 1);
182202

183-
setTimeout(playButtonUpdater, 100);
203+
if (playbackSpeed === 'realtime') {
204+
scheduleNextPlaybackStep();
205+
} else {
206+
setTimeout(playButtonUpdater, getPlaybackDelay());
207+
}
184208
}
185209
};
186210

@@ -190,7 +214,11 @@ const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
190214
if (!sliderPlaying) {
191215
if (getSliderPosition() === sliderLength) setSliderPosition(0);
192216
sliderPlaying = true;
193-
playButtonUpdater();
217+
if (playbackSpeed === 'realtime') {
218+
scheduleNextPlaybackStep();
219+
} else {
220+
playButtonUpdater();
221+
}
194222
} else {
195223
sliderPlaying = false;
196224
}
@@ -202,6 +230,10 @@ const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
202230
setSliderPosition,
203231
getSliderLength,
204232
setSliderLength,
233+
setPlaybackSpeed: (value) => {
234+
playbackSpeed = value;
235+
},
236+
getPlaybackSpeed: () => playbackSpeed,
205237
isSliderActive: () => sliderActive,
206238
playpause,
207239
addSavedRevision,

src/static/js/timeslider.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const socketio = require('./socketio');
3434
import html10n from '../js/vendors/html10n'
3535
let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider;
3636
let cp = '';
37+
const playbackSpeedCookie = 'timesliderPlaybackSpeed';
3738

3839
const init = () => {
3940
padutils.setupGlobalExceptionHandler();
@@ -112,6 +113,7 @@ const fireWhenAllScriptsAreLoaded = [];
112113
const handleClientVars = (message) => {
113114
// save the client Vars
114115
window.clientVars = message.data;
116+
cp = window.clientVars.cookiePrefix || '';
115117

116118
if (window.clientVars.sessionRefreshInterval) {
117119
const ping =
@@ -172,6 +174,15 @@ const handleClientVars = (message) => {
172174
$('#viewfontmenu').on('change', function () {
173175
$('#innerdocbody').css('font-family', $(this).val() || '');
174176
});
177+
178+
const savedPlaybackSpeed = Cookies.get(`${cp}${playbackSpeedCookie}`) || '100';
179+
$('#playbackspeed').val(savedPlaybackSpeed);
180+
BroadcastSlider.setPlaybackSpeed(savedPlaybackSpeed);
181+
$('#playbackspeed').on('change', function () {
182+
const speed = String($(this).val() || '100');
183+
Cookies.set(`${cp}${playbackSpeedCookie}`, speed);
184+
BroadcastSlider.setPlaybackSpeed(speed);
185+
});
175186
};
176187

177188
exports.baseURL = '';

src/templates/timeslider.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,16 @@ <h1 data-l10n-id="pad.settings.padSettings"></h1>
238238
<input type="checkbox" id="options-followContents" checked="checked">
239239
<label for="options-followContents" data-l10n-id="timeslider.followContents"></label>
240240
</p>
241+
<p>
242+
<label for="playbackspeed" data-l10n-id="timeslider.settings.playbackSpeed">Playback speed:</label>
243+
<select id="playbackspeed">
244+
<option value="100" data-l10n-id="timeslider.settings.playbackSpeed.original">Original speed</option>
245+
<option value="realtime" data-l10n-id="timeslider.settings.playbackSpeed.realtime">Realtime</option>
246+
<option value="200" data-l10n-id="timeslider.settings.playbackSpeed.200ms">200 ms</option>
247+
<option value="500" data-l10n-id="timeslider.settings.playbackSpeed.500ms">500 ms</option>
248+
<option value="1000" data-l10n-id="timeslider.settings.playbackSpeed.1000ms">1000 ms</option>
249+
</select>
250+
</p>
241251
</div></div>
242252
</div>
243253
</body>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {expect, Page, test} from "@playwright/test";
2+
import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper";
3+
4+
test.describe('timeslider playback speed', function () {
5+
test.describe.configure({mode: 'serial'});
6+
7+
test.beforeEach(async ({context}) => {
8+
await context.clearCookies();
9+
});
10+
11+
const waitForTimesliderReady = async (page: Page) => {
12+
await page.waitForSelector('#timeslider-wrapper', {state: 'visible'});
13+
await page.waitForFunction(() => {
14+
return Boolean(document.querySelector('#playpause_button_icon')?.getAttribute('title'));
15+
});
16+
};
17+
18+
test('defaults to original speed with no cookies', async function ({page}) {
19+
const padId = await goToNewPad(page);
20+
await clearPadContent(page);
21+
await writeToPad(page, 'One');
22+
await page.waitForTimeout(1000);
23+
24+
await page.goto(`http://localhost:9001/p/${padId}/timeslider#0`);
25+
await waitForTimesliderReady(page);
26+
27+
await expect.poll(async () => await page.evaluate(() => {
28+
const select = document.querySelector('#playbackspeed') as HTMLSelectElement | null;
29+
return {
30+
value: select?.value,
31+
firstOptionText: select?.options[0]?.text,
32+
selectedText: select?.options[select.selectedIndex]?.text,
33+
};
34+
})).toEqual({
35+
value: '100',
36+
firstOptionText: 'Original speed',
37+
selectedText: 'Original speed',
38+
});
39+
});
40+
41+
test('persists the selected playback speed', async function ({page}) {
42+
const padId = await goToNewPad(page);
43+
await clearPadContent(page);
44+
await writeToPad(page, 'One');
45+
await page.waitForTimeout(300);
46+
await writeToPad(page, ' Two');
47+
await page.waitForTimeout(1000);
48+
49+
await page.goto(`http://localhost:9001/p/${padId}/timeslider#1`);
50+
await waitForTimesliderReady(page);
51+
52+
await page.evaluate(() => {
53+
const select = document.querySelector('#playbackspeed') as HTMLSelectElement;
54+
select.value = '500';
55+
select.dispatchEvent(new Event('change', {bubbles: true}));
56+
});
57+
58+
await expect.poll(async () => await page.evaluate(() => {
59+
const select = document.querySelector('#playbackspeed') as HTMLSelectElement | null;
60+
return {
61+
controlValue: select?.value,
62+
};
63+
})).toEqual({controlValue: '500'});
64+
65+
await page.reload();
66+
await waitForTimesliderReady(page);
67+
68+
await expect.poll(async () => await page.evaluate(() => {
69+
const select = document.querySelector('#playbackspeed') as HTMLSelectElement | null;
70+
return {
71+
controlValue: select?.value,
72+
};
73+
})).toEqual({controlValue: '500'});
74+
});
75+
76+
test('uses revision timestamps for realtime playback', async function ({page}) {
77+
const padId = await goToNewPad(page);
78+
await clearPadContent(page);
79+
await writeToPad(page, 'A');
80+
await page.waitForTimeout(1000);
81+
82+
await page.goto(`http://localhost:9001/p/${padId}/timeslider#0`);
83+
await waitForTimesliderReady(page);
84+
85+
const scheduledDelays = await page.evaluate(() => {
86+
(window as any).revisionInfo.getPath = () => ({
87+
status: 'complete',
88+
times: [1234],
89+
});
90+
const select = document.querySelector('#playbackspeed') as HTMLSelectElement;
91+
select.value = 'realtime';
92+
select.dispatchEvent(new Event('change', {bubbles: true}));
93+
(window as any).__playbackTimeouts = [];
94+
window.setTimeout = ((fn: TimerHandler, delay?: number, ...args: any[]) => {
95+
(window as any).__playbackTimeouts.push({
96+
delay,
97+
name: typeof fn === 'function' ? fn.name : String(fn),
98+
});
99+
return 1 as any;
100+
}) as typeof window.setTimeout;
101+
(document.querySelector('#playpause_button_icon') as HTMLButtonElement).click();
102+
return (window as any).__playbackTimeouts;
103+
});
104+
105+
const scheduledDelay = scheduledDelays[0]?.delay;
106+
expect(scheduledDelay).toBe(1234);
107+
});
108+
});

0 commit comments

Comments
 (0)