diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 69ae84544..b25dc7319 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3221,5 +3221,9 @@ "Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد", "World time": "وقت العالم", "Back to live": "العودة إلى البث المباشر", - "Live": "مباشر" + "Live": "مباشر", + "1 h/s": "ساعة/ث", + "1 d/s": "يوم/ث", + "1 wk/s": "أسبوع/ث", + "Play speed": "سرعة التشغيل" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index da1bebb4a..d4d0e6949 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2941,5 +2941,9 @@ "PAD / RTLS": "STARTPLATZ / RTLS", "World time": "Weltzeit", "Back to live": "Zurück zu live", - "Live": "Live" + "Live": "Live", + "1 h/s": "1 Std./s", + "1 d/s": "1 Tag/s", + "1 wk/s": "1 Wo./s", + "Play speed": "Wiedergabegeschwindigkeit" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f592a72c5..d27be0acd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3011,5 +3011,9 @@ "Right panels": "Paneles derechos", "World time": "Hora mundial", "Back to live": "Volver al directo", - "Live": "En directo" + "Live": "En directo", + "1 h/s": "1 h/s", + "1 d/s": "1 d/s", + "1 wk/s": "1 sem/s", + "Play speed": "Velocidad de reproducción" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4d7289ff7..8b225e347 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3011,5 +3011,9 @@ "PAD / RTLS": "PAS DE TIR / RTLS", "World time": "Heure du monde", "Back to live": "Revenir au direct", - "Live": "Direct" + "Live": "Direct", + "1 h/s": "1 h/s", + "1 d/s": "1 j/s", + "1 wk/s": "1 sem/s", + "Play speed": "Vitesse de lecture" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 065137689..29f832bcc 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3011,5 +3011,9 @@ "PAD / RTLS": "SITO DI LANCIO / RTLS", "World time": "Ora del mondo", "Back to live": "Torna alla diretta", - "Live": "Diretta" + "Live": "Diretta", + "1 h/s": "1 h/s", + "1 d/s": "1 g/s", + "1 wk/s": "1 sett/s", + "Play speed": "Velocità di riproduzione" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3ff743076..efbf7d3a2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2871,5 +2871,9 @@ "PAD / RTLS": "射場 / RTLS", "World time": "世界時刻", "Back to live": "ライブに戻る", - "Live": "ライブ" + "Live": "ライブ", + "1 h/s": "1時間/秒", + "1 d/s": "1日/秒", + "1 wk/s": "1週間/秒", + "Play speed": "再生速度" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 455515ab2..7606a3069 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3011,5 +3011,9 @@ "PAD / RTLS": "PLATAFORMA / RTLS", "World time": "Hora do mundo", "Back to live": "Voltar ao vivo", - "Live": "Ao vivo" + "Live": "Ao vivo", + "1 h/s": "1 h/s", + "1 d/s": "1 d/s", + "1 wk/s": "1 sem/s", + "Play speed": "Velocidade de reprodução" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 90099d66c..b79bcc88c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3081,5 +3081,9 @@ "Right panels": "Правые панели", "World time": "Мировое время", "Back to live": "Вернуться к прямому эфиру", - "Live": "Сейчас" + "Live": "Сейчас", + "1 h/s": "1 ч/с", + "1 d/s": "1 дн/с", + "1 wk/s": "1 нед/с", + "Play speed": "Скорость воспроизведения" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 6ca398f07..fda4b5a9b 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3081,5 +3081,9 @@ "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS", "World time": "Світовий час", "Back to live": "Повернутися до прямого ефіру", - "Live": "Зараз" + "Live": "Зараз", + "1 h/s": "1 год/с", + "1 d/s": "1 дн/с", + "1 wk/s": "1 тиж/с", + "Play speed": "Швидкість відтворення" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 019e97701..fc5101fa8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2871,5 +2871,9 @@ "Right panels": "右侧面板", "World time": "世界时间", "Back to live": "返回实时", - "Live": "实时" + "Live": "实时", + "1 h/s": "1 小时/秒", + "1 d/s": "1 天/秒", + "1 wk/s": "1 周/秒", + "Play speed": "播放速度" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 3e41803e6..3411c3838 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2871,5 +2871,9 @@ "Right panels": "右側面板", "World time": "世界時間", "Back to live": "返回即時", - "Live": "即時" + "Live": "即時", + "1 h/s": "1 小時/秒", + "1 d/s": "1 天/秒", + "1 wk/s": "1 週/秒", + "Play speed": "播放速度" } diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 68fa976fa..f9c16c5d8 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -96,6 +96,8 @@ export function createWorldClock({ timeMs: time(), playing, rate, + // A driver steps the clock, at its own pace rather than the rate. + stepping: Boolean(active), following: followedIds(), supported: isSupported(), }); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index bec2a6a82..46c620ab6 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -8,12 +8,17 @@ const LIVE_TICK_MS = 1000; const LIVE_EDGE = 0.005; /** The least that counts as at now, past the readout's own second. */ const LIVE_EDGE_MS = 5000; -/** How long play takes to run the whole track, in real ms. */ -const PLAY_THROUGH_MS = 60_000; +/** The speeds play offers, in world ms per real ms. */ +export const WORLD_TIME_SPEEDS = Object.freeze({ + real: 1, + hour: 3_600, + day: 86_400, + week: 604_800, +}); /** - * The world clock as the timeline shows it: `{live, playing, timeMs, - * supported, span}`, where `supported` says a layer that moves with the clock + * The world clock as the timeline shows it: `{live, playing, stepping, + * timeMs, supported, span}`, where `supported` says a layer that moves with the clock * has time to show and `span` is the stretch the timeline runs over: the * time those layers cover, reaching to now and to the world time, with their * marks. It follows the page's world clock, which tells it of every move, and @@ -75,11 +80,14 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { Object.freeze({ live: state.live, playing: state.playing, + stepping: state.stepping, timeMs: state.timeMs, supported: state.supported, span: spanOf(state), }); const time = shallowRef(read()); + const speed = shallowRef('day'); + const rate = () => WORLD_TIME_SPEEDS[speed.value]; let ticker = null; const syncTicker = () => { // Playing, the clock tells of the time itself. @@ -108,8 +116,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { return { time, /** - * Play runs the whole track in about a minute, from the start when it is - * at its end (at live, say), as a video plays again from the top. It runs + * Play runs the track at the speed chosen, from the start when it is at + * its end (at live, say), as a video plays again from the top. It runs * the clock itself, past weather's own replay: weather follows the time * frame by frame like every other layer the track covers. */ @@ -117,15 +125,24 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { const { playing, live, timeMs, span } = time.value; if (playing) return worldClock.pause(); const covered = reach(); - if (!span || !covered) return worldClock.play({ rate: 1 }); - const length = span.end - span.start; - if (live || timeMs >= covered.end - LIVE_EDGE_MS) + if (span && covered && (live || timeMs >= covered.end)) worldClock.seek(span.start); - // An end at now moves on with it, so play outruns it by the track. - const rate = endsAtNow(covered) - ? 1 + length / PLAY_THROUGH_MS - : Math.max(1, length / PLAY_THROUGH_MS); - worldClock.play({ rate, continuous: true }); + worldClock.play({ rate: rate(), continuous: true }); + }, + speed, + /** + * Choose how fast play runs; playing, it changes at once. Weather's own + * replay steps at its own pace, so a speed chosen while it plays takes + * playback over from where it is. + */ + setSpeed(next) { + if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return; + speed.value = next; + const { playing, stepping } = worldClock.getState(); + if (!playing) return; + if (!stepping) return worldClock.setRate(rate()); + worldClock.pause(); + worldClock.play({ rate: rate(), continuous: true }); }, goLive: () => worldClock.goLive(), /** Scrub to `ms`: playback stops where the hand puts it; at now, live. */ diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index e7560a467..0ae69baf4 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -77,6 +77,10 @@ body.cockpit-mode #world-timeline { text-transform: uppercase; } +#world-timeline .world-timeline-speed { + flex: none; +} + #world-timeline .world-timeline-time { overflow: hidden; font-family: var(--font-mono); @@ -206,9 +210,15 @@ body.mobile-shell #world-timeline .world-timeline-time { flex: 1 1 0; } +/* The speed sits on the track's row, a full-size target beside it. */ body.mobile-shell #world-timeline .world-timeline-track { order: 1; - flex-basis: 100%; + flex: 1 1 calc(100% - 96px); +} + +body.mobile-shell #world-timeline .world-timeline-speed { + order: 2; + height: 32px; } @media (max-width: 640px) { diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 1d495d9a2..251ede717 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -149,7 +149,7 @@ test('the track runs over the time the layers show, to now, and scrubs', async ( assert.equal(worldClock.now(), ahead); }); -test('play runs the whole track in a minute, from the start when at live', async (t) => { +test('play runs a day a second, from the start of the track when at live', async (t) => { const bar = mount(t); const now = Date.now(); const start = now - 10 * 86_400_000; @@ -176,9 +176,7 @@ test('play runs the whole track in a minute, from the start when at live', async assert.deepEqual(stepped, []); const rate = worldClock.getRate(); assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track'); - // The end is now and moves on with it: play outruns it by the track. - const length = Date.now() - start; - assert.ok(Math.abs(rate - (1 + length / 60_000)) < rate * 0.01, 'a minute end to end'); + assert.equal(rate, 86_400, 'a day a second'); worldClock.play({ continuous: true }); // Playing on to now goes live. @@ -218,3 +216,86 @@ test('play holds at the end of a track that reaches past now', async (t) => { assert.equal(worldClock.isLive(), false); assert.equal(worldClock.now(), end); }); + +test('the speed chosen sets how fast play runs, at once while playing', async (t) => { + const bar = mount(t); + const now = Date.now(); + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start: now - 10 * 86_400_000, end: now - 86_400_000 }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const select = bar().querySelector('select.world-timeline-speed'); + assert.equal(select.value, 'day'); + select.value = 'week'; + select.dispatchEvent(new Event('change')); + await nextTick(); + bar().querySelector('.world-timeline-play').click(); + assert.equal(worldClock.getRate(), 604_800, 'a week a second'); + select.value = 'hour'; + select.dispatchEvent(new Event('change')); + await nextTick(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getRate(), 3_600, 'changed while playing'); + select.value = 'real'; + select.dispatchEvent(new Event('change')); + await nextTick(); + assert.equal(worldClock.getRate(), 1, 'the world as it goes'); +}); + +test('a speed chosen while weather replay steps takes playback over', async (t) => { + const bar = mount(t); + const at = Date.now() - 3_600_000; + supportingLayer(t, at); + const calls = []; + const driver = worldClock.attachDriver({ + play() { + calls.push('play'); + driver.driven(true, at - 600_000); + }, + pause() { + calls.push('pause'); + driver.driven(false); + }, + }); + t.after(() => driver.detach()); + worldClock.play(); + await nextTick(); + assert.equal(worldClock.getState().stepping, true); + const select = bar().querySelector('select.world-timeline-speed'); + select.value = 'hour'; + select.dispatchEvent(new Event('change')); + await nextTick(); + assert.deepEqual(calls, ['play', 'pause']); + assert.equal(worldClock.getState().stepping, false); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getRate(), 3_600); + assert.ok(worldClock.now() - (at - 600_000) < 3_600 * 1_000, 'on from where it was'); + worldClock.pause(); +}); + +test('paused just before the end, play carries on rather than restarting', async (t) => { + const bar = mount(t); + const now = Date.now(); + const end = now + 86_400_000; + const release = worldClock.hold('test-ahead'); + const unregister = worldClock.register({ + id: 'test-ahead', + coverage: () => ({ start: now - 86_400_000, end }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + worldClock.seek(end - 4_000); + await nextTick(); + bar().querySelector('.world-timeline-play').click(); + worldClock.pause(); + assert.ok(worldClock.now() >= end - 4_000, 'not back to the start'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 1c4a8bdc0..0cedda02d 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -5,6 +5,7 @@ import { formatDate, t } from '../../../i18n/index.js'; import { useWorldTimeStore } from '../../stores/worldTime.js'; import UiButton from '../../kit/UiButton.vue'; import UiIcon from '../../kit/UiIcon.vue'; +import UiSelect from '../../kit/UiSelect.js'; // The world timeline: play and pause on the left, the time, a track across // the stretch the layers on can show (each launch or frame a tick) with a @@ -13,7 +14,13 @@ import UiIcon from '../../kit/UiIcon.vue'; // or one a host follows) is on with time to show; with only live feeds such // as flights on, the bar stays hidden. const store = useWorldTimeStore(); -const { time } = storeToRefs(store); +const { time, speed } = storeToRefs(store); +const speeds = computed(() => [ + { value: 'real', label: '1×' }, + { value: 'hour', label: t('1 h/s') }, + { value: 'day', label: t('1 d/s') }, + { value: 'week', label: t('1 wk/s') }, +]); const DATE_TIME = Object.freeze({ dateStyle: 'medium', @@ -72,7 +79,8 @@ const onKey = (event) => {