diff --git a/data/locales/pages.en.yml b/data/locales/pages.en.yml
index efd19eb0..a8735062 100644
--- a/data/locales/pages.en.yml
+++ b/data/locales/pages.en.yml
@@ -457,6 +457,25 @@ en:
credits_title: Standing on open shoulders
cta_chat: Join the FPV chat
cta_guide: Read the build guide
+ flight_copy: H.264 copy
+ flight_credit_html: 'Flight by gilankpam on mabur · SSC338Q + RTL8812EU'
+ flight_error: This browser cannot play the stream
+ flight_failed: 'The flight did not load. Try again'
+ flight_frame_back: Previous frame
+ flight_frame_forward: Next frame
+ flight_fullscreen: Full screen
+ flight_gs: On the pilot's screen
+ flight_handle: Divider between the two recordings
+ flight_loading: Loading…
+ flight_onboard: On the drone
+ flight_open_rnd: Open in R&D Player
+ flight_original: Original quality
+ flight_pause: Pause
+ flight_play: Play
+ flight_reduced: reduced
+ flight_seek: Position in the flight
+ flight_text: 'The drone''s own recording and what reached the pilot''s screen over the link, frame by frame. Drag the divider to compare; the link''s latency is in the telemetry along the bottom.'
+ flight_title: 'See it on a real flight'
fpv_text: 'A fully open digital FPV system: no locked ecosystem, no paired hardware, no firmware ceiling. Build the air unit from a supported camera, pick your ground station, and tune every stage of the pipeline.'
fpv_title: 'FPV: from lens to goggles'
hardware_text_html: Air units are built from supported cameras and AIO boards — start from the supported hardware list. RunCam and other vendors ship OpenIPC-based FPV hardware out of the box.
diff --git a/data/locales/pages.ru.yml b/data/locales/pages.ru.yml
index 0824d9b5..5d05556c 100644
--- a/data/locales/pages.ru.yml
+++ b/data/locales/pages.ru.yml
@@ -495,6 +495,25 @@ ru:
credits_title: Стоим на открытых плечах
cta_chat: FPV-чат
cta_guide: Гайд по сборке
+ flight_copy: копия H.264
+ flight_credit_html: 'Полёт — gilankpam на mabur · SSC338Q + RTL8812EU'
+ flight_error: Этот браузер не может воспроизвести поток
+ flight_failed: 'Полёт не загрузился. Попробуйте ещё раз'
+ flight_frame_back: Предыдущий кадр
+ flight_frame_forward: Следующий кадр
+ flight_fullscreen: Во весь экран
+ flight_gs: На экране пилота
+ flight_handle: Граница между двумя записями
+ flight_loading: Загрузка…
+ flight_onboard: На борту дрона
+ flight_open_rnd: Открыть в R&D Player
+ flight_original: Исходное качество
+ flight_pause: Пауза
+ flight_play: Смотреть
+ flight_reduced: снижено
+ flight_seek: Позиция в полёте
+ flight_text: 'Запись с борта дрона и то, что дошло по линку до экрана пилота, кадр в кадр. Двигайте границу, чтобы сравнить; задержка линка — в телеметрии внизу.'
+ flight_title: 'Посмотрите на настоящем полёте'
fpv_text: 'Полностью открытая цифровая FPV-система: никакой закрытой экосистемы, никакого спаренного железа, никакого потолка прошивки. Соберите борт из поддерживаемой камеры, выберите наземную станцию и настройте каждый этап конвейера.'
fpv_title: 'FPV: от объектива до очков'
hardware_text_html: Борт собирается из поддерживаемых камер и AIO-плат — начните со списка поддерживаемого оборудования. RunCam и другие вендоры выпускают FPV-железо на OpenIPC из коробки.
diff --git a/data/locales/pages.zh.yml b/data/locales/pages.zh.yml
index 4735253b..8297cb5d 100644
--- a/data/locales/pages.zh.yml
+++ b/data/locales/pages.zh.yml
@@ -457,6 +457,25 @@ zh:
credits_title: 站在开放的肩膀上
cta_chat: 加入 FPV 聊天群
cta_guide: 阅读搭建指南
+ flight_copy: H.264 副本
+ flight_credit_html: '飞行:gilankpam,使用 mabur · SSC338Q + RTL8812EU'
+ flight_error: 此浏览器无法播放该视频流
+ flight_failed: '飞行视频未能加载,请重试'
+ flight_frame_back: 上一帧
+ flight_frame_forward: 下一帧
+ flight_fullscreen: 全屏
+ flight_gs: 飞手屏幕上
+ flight_handle: 两段录像之间的分隔线
+ flight_loading: 加载中…
+ flight_onboard: 无人机机载
+ flight_open_rnd: 在 R&D Player 中打开
+ flight_original: 原始画质
+ flight_pause: 暂停
+ flight_play: 播放
+ flight_reduced: 已降低
+ flight_seek: 飞行中的位置
+ flight_text: '无人机机载录像与经链路到达飞手屏幕的画面,逐帧对照。拖动分隔线进行比较;链路延迟显示在底部的遥测中。'
+ flight_title: '在真实飞行中看看'
fpv_text: '一套完全开放的数字 FPV 系统:没有封闭生态,没有配对绑定的硬件,也没有固件上限。用受支持的摄像机搭建天空端,挑选你的地面站,并可调节管线的每一个环节。'
fpv_title: 'FPV:从镜头到眼镜'
hardware_text_html: 天空端由受支持的摄像机和 AIO 板搭建——请从支持硬件列表开始。RunCam 等厂商出厂即提供基于 OpenIPC 的 FPV 硬件。
diff --git a/deploy/RESTORE.md b/deploy/RESTORE.md
index 26be6e4e..51b6bef2 100644
--- a/deploy/RESTORE.md
+++ b/deploy/RESTORE.md
@@ -310,6 +310,12 @@ Only needed on a rebuilt host:
- `/srv/www/shared/wall` — the wall's images. **Not** in the backup; the Open
Wall is empty until cameras re-upload, so an empty directory owned by uid
1000 is a complete restore.
+- `/srv/www/shared/media` — the recorded flight the A/B player on
+ `/low-latency` streams. **Not** in the backup: about 2.5 GB, rebuilt from the
+ two source recordings by `tools/flight-ab/run.sh` and uploaded as its README
+ says. The sources are on this host too (`/srv/www/flight-sources/`, not in
+ S3), so a rebuilt host needs them from wherever else they were kept. Until then the page shows its poster and the player says it cannot
+ play.
- **analytics**, via `deploy/install-analytics.sh` (#181). It installs
GoatCounter, its account and its systemd unit, and creates the site on first
run from `ANALYTICS_EMAIL` and `ANALYTICS_PASSWORD`. The SQLite database is
diff --git a/deploy/nginx/check-config.sh b/deploy/nginx/check-config.sh
index 48d7dbcf..84ef86c0 100755
--- a/deploy/nginx/check-config.sh
+++ b/deploy/nginx/check-config.sh
@@ -191,6 +191,10 @@ ln -s site-test /srv/www/static/prod/current
printf 'TOKEN-OK\n' > /var/lib/dehydrated/acme-challenges/probe-token
install -d -m 0755 /srv/www/shared/images
printf 'PNG\n' > /srv/www/shared/images/logo_openipc.png
+# A recorded flight for the A/B player on /low-latency (tools/flight-ab).
+install -d -m 0755 /srv/www/shared/media/flights/test/v1/gs/original
+printf '\n' > /srv/www/shared/media/flights/test/v1/gs.mpd
+printf '0123456789' > /srv/www/shared/media/flights/test/v1/gs/original/1.m4s
# A second loopback address stands in for a datacentre client, so the block
# and CI's way through it are measured rather than read off the map.
@@ -440,6 +444,30 @@ expect_cache /no-such-page-at-all "no-cache"
expect /fonts/ibm-plex-sans-latin-400-normal.woff2 200 static hsts
expect_cache /fonts/ibm-plex-sans-latin-400-normal.woff2 "public, max-age=31536000, immutable"
expect /assets/application.css 410 - no-hsts
+
+# Recorded flights (tools/flight-ab): versioned directories never rewritten, so
+# a year of cache; the types DASH players insist on; ranges, which a player
+# may ask for; and CORS for the R&D Player's origin and nobody else's.
+expect /media/flights/test/v1/gs.mpd 200 - hsts
+expect_cache /media/flights/test/v1/gs.mpd "public, max-age=31536000, immutable"
+media_header() {
+ path=$1; header=$2; want=$3; shift 3
+ curl -sS -o /dev/null -D /tmp/hm -k --max-time 5 "$@" \
+ --resolve "openipc.org:443:127.0.0.1" "https://openipc.org$path" >/dev/null 2>&1
+ if [ "$header" = status ]; then got=$(awk 'NR==1{print $2}' /tmp/hm)
+ else got=$(grep -i "^$header:" /tmp/hm | tr -d '\r' | cut -d' ' -f2- | head -1); fi
+ if [ "$got" = "$want" ]; then
+ printf ' %-32s %s: %s\n' "$path" "$header" "$got"
+ else
+ printf ' %-32s MISMATCH: %s is %s (want %s)\n' "$path" "$header" "${got:-}" "$want"
+ fail=1
+ fi
+}
+media_header /media/flights/test/v1/gs.mpd content-type application/dash+xml
+media_header /media/flights/test/v1/gs/original/1.m4s content-type video/iso.segment
+media_header /media/flights/test/v1/gs/original/1.m4s status 206 -H 'Range: bytes=2-5'
+media_header /media/flights/test/v1/gs.mpd access-control-allow-origin https://openipc.github.io
+expect /media/flights/test/v1/none.mpd 404 - no-hsts
# The home page keeps its address when its content changes, like every other
# page in the bundle, so it may be cached and must always be revalidated.
expect_cache / "public, max-age=0, must-revalidate"
diff --git a/deploy/nginx/sites-available/org.openipc b/deploy/nginx/sites-available/org.openipc
index c33a6b32..9e5a1ab1 100644
--- a/deploy/nginx/sites-available/org.openipc
+++ b/deploy/nginx/sites-available/org.openipc
@@ -202,6 +202,32 @@ server {
alias /srv/www/shared/images/;
}
+ # Recorded flights, streamed as DASH by the A/B player on /low-latency
+ # (tools/flight-ab). Each flight is one versioned directory that is never
+ # rewritten -- a new cut is a new v/ -- so a year of immutable cache is
+ # safe. Host-only and in no backup: the tree is regenerated from the two
+ # source recordings by tools/flight-ab/run.sh.
+ #
+ # The CORS header is for one origin only, the R&D Player's, which the page
+ # offers as "open in R&D Player" and which fetches the manifests and
+ # segments cross-origin. Both players fetch a segment per stream at a
+ # time, well inside the http-level per_subnet twenty.
+ location ^~ /media/ {
+ alias /srv/www/shared/media/;
+ types {
+ application/dash+xml mpd;
+ video/iso.segment m4s;
+ video/mp4 mp4;
+ application/json json;
+ image/jpeg jpg;
+ }
+ add_header Strict-Transport-Security max-age=15768000;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Access-Control-Allow-Origin https://openipc.github.io always;
+ # Not `always`: a 404 cached for a year would outlive the upload.
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ }
+
# Analytics beacon (#181). GoatCounter runs on the loopback and these are
# the only two paths of it the public site exposes.
#
diff --git a/deploy/nginx/sites-available/org.openipc.dev b/deploy/nginx/sites-available/org.openipc.dev
index 13f62f07..aadc1090 100644
--- a/deploy/nginx/sites-available/org.openipc.dev
+++ b/deploy/nginx/sites-available/org.openipc.dev
@@ -772,6 +772,33 @@ server {
}
+ # Recorded flights, streamed as DASH by the A/B player on /low-latency
+ # (tools/flight-ab). Each flight is one versioned directory that is never
+ # rewritten -- a new cut is a new v/ -- so a year of immutable cache is
+ # safe. Host-only and in no backup: the tree is regenerated from the two
+ # source recordings by tools/flight-ab/run.sh.
+ #
+ # The CORS header is for one origin only, the R&D Player's, which the page
+ # offers as "open in R&D Player" and which fetches the manifests and
+ # segments cross-origin. Both players fetch a segment per stream at a
+ # time, well inside the http-level per_subnet twenty.
+ location ^~ /media/ {
+ alias /srv/www/shared/media/;
+ types {
+ application/dash+xml mpd;
+ video/iso.segment m4s;
+ video/mp4 mp4;
+ application/json json;
+ image/jpeg jpg;
+ }
+ add_header Strict-Transport-Security max-age=15768000;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Access-Control-Allow-Origin https://openipc.github.io always;
+ # Not `always`: a 404 cached for a year would outlive the upload.
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
+ }
+
# The Go firmware process's image cache (#299): one version of each image,
# built on first request. Reachable only through its X-Accel-Redirect.
location /firmware-cache/ {
diff --git a/deploy/static/reserved-paths b/deploy/static/reserved-paths
index 6a42b3cd..a5cd3f75 100644
--- a/deploy/static/reserved-paths
+++ b/deploy/static/reserved-paths
@@ -54,6 +54,8 @@
/board-files/
# Owner reports' files, internal: reached only through the web role's X-Accel-Redirect for a published report.
/report-files/
+# Recorded flights for the A/B player on /low-latency (tools/flight-ab), host-only.
+/media/
# --- the expensive leaf ------------------------------------------------------
# /cameras/ is deliberately NOT reserved: the catalogue is exactly what #162
diff --git a/frontend/apps/site/package.json b/frontend/apps/site/package.json
index bd716581..ab4032c3 100644
--- a/frontend/apps/site/package.json
+++ b/frontend/apps/site/package.json
@@ -18,7 +18,8 @@
"dependencies": {
"@openipc/ui": "*",
"d3-hierarchy": "^3.1.2",
- "preact": "10.27.2"
+ "preact": "10.27.2",
+ "shaka-player": "^4.16.51"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
diff --git a/frontend/apps/site/src/assets/pages/flight-mabur-2026-10.jpg b/frontend/apps/site/src/assets/pages/flight-mabur-2026-10.jpg
new file mode 100644
index 00000000..3271c36d
Binary files /dev/null and b/frontend/apps/site/src/assets/pages/flight-mabur-2026-10.jpg differ
diff --git a/frontend/apps/site/src/components/flight/FlightCompare.tsx b/frontend/apps/site/src/components/flight/FlightCompare.tsx
new file mode 100644
index 00000000..ae1d05e2
--- /dev/null
+++ b/frontend/apps/site/src/components/flight/FlightCompare.tsx
@@ -0,0 +1,419 @@
+/**
+ * A real flight, as the drone recorded it and as the pilot saw it.
+ *
+ * Two recordings of one flight, cut onto one timeline by tools/flight-ab and
+ * streamed as two DASH presentations from /media/. The drone's side is on top,
+ * clipped at the split; the ground station's is underneath. Same arrangement
+ * and the same way of keeping them on one frame as the R&D Player's compare
+ * (../../lib/flight-sync.ts), without the player around it.
+ *
+ * Nothing is fetched until the reader presses play: before that this is a
+ * poster, so the page weighs what it did. Shaka arrives then, as its own
+ * chunk.
+ *
+ * Each side adapts on its own, and each says what it is showing, because a
+ * comparison where one side has quietly dropped to 540p is not one. "Original
+ * quality" pins both to the recordings themselves.
+ */
+import { useEffect, useRef, useState } from 'preact/hooks';
+import type shakaNs from 'shaka-player/dist/shaka-player.dash.js';
+import { clock, originalOf, rungKind, SEEK_DRIFT, syncAction, type RungKind } from '../../lib/flight-sync';
+
+type Shaka = typeof shakaNs;
+type Player = InstanceType;
+type Side = 'onboard' | 'gs';
+
+export interface FlightLabels {
+ play: string;
+ pause: string;
+ loading: string;
+ error: string;
+ failed: string;
+ onboard: string;
+ gs: string;
+ original: string;
+ reduced: string;
+ copy: string;
+ fullscreen: string;
+ frameBack: string;
+ frameForward: string;
+ handle: string;
+ seek: string;
+ openRnd: string;
+}
+
+interface Props {
+ /** The flight's directory under /media/, with a trailing slash. */
+ base: string;
+ poster: string;
+ /** The common window, on the drone's timeline, from stats.json. */
+ start: number;
+ end: number;
+ rndPlayer: string;
+ labels: FlightLabels;
+}
+
+type Phase = 'idle' | 'loading' | 'ready' | 'failed' | 'unsupported';
+interface Badge { height: number | null; kind: RungKind }
+
+const FRAME = 1 / 60;
+
+// Drawn rather than typed: ⏮ ⏭ ⇆ are missing from enough system fonts to
+// arrive as empty boxes.
+const Glyph = ({ d }: { d: string }) => (
+
+);
+const STEP_BACK = 'M3 3h2v10H3zM13 3v10L6 8z';
+const STEP_FORWARD = 'M11 3h2v10h-2zM3 3v10l7-5z';
+const BOTH_WAYS = 'M1 8l4-4v3h6V4l4 4-4 4V9H5v3z';
+
+export default function FlightCompare({ base, poster, start, end, rndPlayer, labels }: Props) {
+ // Never exactly on the ground station's first frame: a playhead a rounding
+ // error before its buffered range has no picture, and Shaka steps over that
+ // gap only while the video plays -- which a side held for the other never
+ // does, so both waited for good. Five milliseconds in is the same frame.
+ const begin = start + 0.005;
+ const box = useRef(null);
+ const lead = useRef(null);
+ const follow = useRef(null);
+ const players = useRef>>({});
+ const held = useRef(false);
+
+ const [phase, setPhase] = useState('idle');
+ const [playing, setPlaying] = useState(false);
+ const [time, setTime] = useState(begin);
+ const [split, setSplit] = useState(50);
+ const [pinned, setPinned] = useState(false);
+ const [badges, setBadges] = useState>>({});
+
+ const describe = (side: Side) => {
+ const p = players.current[side];
+ if (!p) return;
+ const tracks = p.getVariantTracks();
+ const active = tracks.find((t) => t.active);
+ if (!active) return;
+ setBadges((b) => ({ ...b, [side]: { height: active.height, kind: rungKind(side, active, tracks) } }));
+ };
+
+ // Both players go together: one left attached beside a failed other would
+ // keep its buffers for nothing.
+ async function teardown() {
+ const ps = Object.values(players.current);
+ players.current = {};
+ held.current = false;
+ setBadges({});
+ setPlaying(false);
+ await Promise.all(ps.map((p) => p?.destroy().catch(() => {})));
+ }
+
+ // A browser without MSE is told so; anything else -- the media tree missing,
+ // a network failure, a stream that breaks mid-flight -- is a failure the
+ // reader can retry, and is logged rather than blamed on the browser.
+ async function fail(err: unknown) {
+ console.error('flight: the streams failed', err);
+ await teardown();
+ setPhase('failed');
+ }
+
+ async function load() {
+ setPhase('loading');
+ try {
+ const { default: shaka } = await import('shaka-player/dist/shaka-player.dash.js');
+ shaka.polyfill.installAll();
+ if (!shaka.Player.isBrowserSupported()) { setPhase('unsupported'); return; }
+ const sides: [Side, HTMLVideoElement][] = [['onboard', lead.current!], ['gs', follow.current!]];
+ await Promise.all(sides.map(async ([side, video]) => {
+ const p = new shaka.Player();
+ await p.attach(video);
+ p.configure({
+ // HEVC first: the ground station's recording is HEVC, and its H.264
+ // rungs are for browsers that cannot decode it.
+ preferredVideoCodecs: ['hvc1', 'hev1', 'avc1'],
+ // No playRangeStart: Shaka makes it the MSE append window, and the
+ // browser then drops every frame up to the next keyframe -- 4 s of
+ // the drone's side. The window is kept by the controls below instead.
+ streaming: { bufferingGoal: 20, rebufferingGoal: 1 },
+ abr: { defaultBandwidthEstimate: 8_000_000 },
+ });
+ p.addEventListener('error', (e) => {
+ const err = (e as unknown as { detail?: { severity?: number } }).detail;
+ if (err?.severity === shaka.util.Error.Severity.CRITICAL) void fail(err);
+ });
+ p.addEventListener('adaptation', () => describe(side));
+ p.addEventListener('variantchanged', () => describe(side));
+ players.current[side] = p;
+ await p.load(`${base}${side}.mpd`, begin);
+ describe(side);
+ }));
+ } catch (err) {
+ await fail(err);
+ return;
+ }
+ setPhase('ready');
+ // Outside the try: a play() cut short by the buffering hold below rejects
+ // with AbortError, which is not the stream failing to load.
+ void lead.current!.play().catch(() => {});
+ }
+
+ // Everything the follower does, it does because the leader did it.
+ useEffect(() => {
+ const a = lead.current, b = follow.current;
+ if (!a || !b || phase !== 'ready') return;
+ const onPlay = () => { setPlaying(true); void b.play().catch(() => {}); };
+ const onPause = () => {
+ // A pause event is dispatched after the fact: one from a hold that has
+ // already been released would otherwise stop the follower for good.
+ if (held.current || !a.paused) return;
+ setPlaying(false);
+ b.pause();
+ b.currentTime = a.currentTime;
+ };
+ const onSeek = () => {
+ // Before the window the ground station has no picture yet.
+ if (a.currentTime < begin) { a.currentTime = begin; return; }
+ setTime(a.currentTime);
+ // Not while the follower is still starting: a seek that lands before
+ // Shaka's first append left it seeking with nothing buffered for good
+ // (about one start in six). The hold below lines it up once it has a
+ // picture.
+ if (b.readyState >= 2) b.currentTime = a.currentTime;
+ };
+ const onEnded = () => { setPlaying(false); b.pause(); };
+ a.addEventListener('play', onPlay);
+ a.addEventListener('pause', onPause);
+ a.addEventListener('seeking', onSeek);
+ a.addEventListener('ended', onEnded);
+
+ let raf = 0;
+ let shown = -1;
+ const tick = () => {
+ raf = requestAnimationFrame(tick);
+ // Read off the element rather than kept from events: the first play()
+ // runs before this effect has attached its listeners.
+ setPlaying(!a.paused || held.current);
+ // The clock under the picture, a few times a second rather than every
+ // frame: it reads in whole seconds.
+ if (Math.abs(a.currentTime - shown) >= 0.25 || a.paused) {
+ shown = a.currentTime;
+ setTime(shown);
+ }
+ // If either side is starved, both wait: a side that ran on alone would
+ // be compared with a picture from seconds before. Seeking and Shaka's
+ // own buffering count as starved -- a leader that kept playing while
+ // the follower sought would be further ahead when the seek landed, and
+ // the follower would chase it for ever.
+ const pa = players.current.onboard, pb = players.current.gs;
+ const aStarved = a.readyState < 3 || a.seeking || !!pa?.isBuffering();
+ const bStarved = b.readyState < 3 || b.seeking || !!pb?.isBuffering();
+ const starved = aStarved || bStarved;
+ if (!a.paused && starved) {
+ held.current = true;
+ a.pause();
+ }
+ if (held.current && starved) {
+ // A starved follower is left playing: it cannot move without data,
+ // and Shaka gets a stalled stream going again only while it plays --
+ // paused while still starting, it was seen to wait for ever. Only a
+ // follower that has data, and would run ahead, is stopped.
+ if (bStarved && b.paused) void b.play().catch(() => {});
+ if (!bStarved && !b.paused) b.pause();
+ return;
+ }
+ if (held.current && !starved) {
+ // Line the follower up with the stopped leader if it is really
+ // elsewhere, then go. Only a real gap: a follower left running while
+ // it waited is a few milliseconds on by the time it is seen ready, and
+ // re-seeking that -- a seek decodes from the keyframe before it -- had
+ // the two take turns being ready for good. The nudge closes the rest.
+ if (Math.abs(b.currentTime - a.currentTime) > SEEK_DRIFT) { b.currentTime = a.currentTime; return; }
+ held.current = false;
+ b.playbackRate = 1;
+ void a.play().catch(() => {});
+ void b.play().catch(() => {});
+ return;
+ }
+ if (a.paused || b.seeking) return;
+ if (b.paused) void b.play().catch(() => {});
+ const act = syncAction(a.currentTime, b.currentTime);
+ if (act.kind === 'seek') b.currentTime = act.to;
+ else if (b.playbackRate !== act.rate) b.playbackRate = act.rate;
+ };
+ raf = requestAnimationFrame(tick);
+ return () => {
+ cancelAnimationFrame(raf);
+ a.removeEventListener('play', onPlay);
+ a.removeEventListener('pause', onPause);
+ a.removeEventListener('seeking', onSeek);
+ a.removeEventListener('ended', onEnded);
+ };
+ }, [phase]);
+
+ useEffect(() => () => {
+ for (const p of Object.values(players.current)) void p?.destroy();
+ }, []);
+
+ async function pin(on: boolean) {
+ setPinned(on);
+ for (const side of ['onboard', 'gs'] as Side[]) {
+ const p = players.current[side];
+ if (!p) continue;
+ p.configure({ abr: { enabled: !on } });
+ if (on) {
+ const top = originalOf(side, p.getVariantTracks());
+ if (top) p.selectVariantTrack(top, true);
+ }
+ describe(side);
+ }
+ }
+
+ const toggle = () => {
+ const a = lead.current;
+ if (phase === 'idle' || phase === 'failed') { void load(); return; }
+ if (!a || phase !== 'ready') return;
+ // Held for a starved side counts as playing: Pause has to stop both,
+ // including a follower left running while it waits for data.
+ if (held.current) { held.current = false; follow.current?.pause(); return; }
+ if (a.paused) void a.play(); else a.pause();
+ };
+
+ const step = (by: number) => {
+ const a = lead.current;
+ if (!a || phase !== 'ready') return;
+ // A step is a decision to stop here: a hold must not resume over it.
+ held.current = false;
+ a.pause();
+ follow.current?.pause();
+ a.currentTime = Math.min(end, Math.max(begin, a.currentTime + by));
+ };
+
+ // The split follows the pointer while it is down anywhere on the picture,
+ // which is what makes it usable on a phone, where the handle is a hairline.
+ const dragging = useRef(false);
+ const place = (clientX: number) => {
+ const r = box.current!.getBoundingClientRect();
+ setSplit(Math.min(100, Math.max(0, ((clientX - r.left) * 100) / r.width)));
+ };
+
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'ArrowLeft') { setSplit((s) => Math.max(0, s - 2)); e.preventDefault(); }
+ if (e.key === 'ArrowRight') { setSplit((s) => Math.min(100, s + 2)); e.preventDefault(); }
+ };
+
+ const clip = `inset(0 ${100 - split}% 0 0)`;
+
+ const badge = (side: Side) => {
+ const b = badges[side];
+ if (!b) return null;
+ const what = b.kind === 'original' ? labels.original : b.kind === 'copy' ? labels.copy : labels.reduced;
+ return (
+ // On a phone only the warning: two full badges do not fit beside the labels.
+
+ {b.height ? `${b.height}p · ` : ''}{what}
+
+ );
+ };
+
+ const showOnboardLabel = split > 0;
+ const showGsLabel = split < 100;
+
+ return (
+
+