diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx
index 22cbc3e385b..d51df1b1fbb 100644
--- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx
+++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx
@@ -21,6 +21,7 @@ import {
customSelfHostedVideoPlayAudioEventName,
customYoutubePlayEventName,
findOptimisedSourcePerMimeType,
+ getVideoStrategy,
} from '../lib/video';
import type { VideoStyleSettings } from '../lib/videoStyleSettings';
import { videoSettingsMap } from '../lib/videoStyleSettings';
@@ -31,6 +32,7 @@ import type { RenderingTarget } from '../types/renderingTarget';
import { Caption } from './Caption';
import { CardPicture, type Props as CardPictureProps } from './CardPicture';
import { useConfig } from './ConfigContext';
+import { SelfHostedVideoDebugOverlay } from './SelfHostedVideoDebugOverlay';
import type {
ControlsPosition,
PLAYER_STATES,
@@ -144,6 +146,7 @@ const fullscreenStyles = css`
/* Override the fixed aspect-ratio + width:100% on the video so it
fits within the screen while preserving its aspect ratio. */
+
video {
width: 100%;
height: 100%;
@@ -650,10 +653,13 @@ export const SelfHostedVideo = ({
}
const screenWidth = window.innerWidth;
+ const strategy = getVideoStrategy();
+ console.log(strategy);
const filteredSources = findOptimisedSourcePerMimeType(
sources,
screenWidth,
- );
+ ).filter((source) => source.mimeType === strategy);
+
setOptimisedSources(filteredSources);
/**
@@ -1137,6 +1143,11 @@ export const SelfHostedVideo = ({
isInteractive={videoStyleSettings.isInteractive}
isWebKitFullscreen={isWebKitFullscreen}
/>
+
{!!caption && format && (
diff --git a/dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx b/dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx
new file mode 100644
index 00000000000..7d6e220b849
--- /dev/null
+++ b/dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx
@@ -0,0 +1,315 @@
+import { css } from '@emotion/react';
+import { headlineMedium14 } from '@guardian/source/foundations';
+import { useEffect, useRef, useState } from 'react';
+import { palette } from '../palette';
+import type { RenderingTarget } from '../types/renderingTarget';
+
+/**
+ * A lightweight, intentionally disposable debug overlay for self-hosted video.
+ *
+ * Enable by appending `?videoDebug=1` (or `&videoDebug=1`) to the page URL.
+ *
+ * Surfaces:
+ * - FORMAT: mime/extension of the selected source
+ * - STARTUP: ms from `loadstart` to first `playing`
+ * - STALLS: count of `waiting`/`stalled` events after initial play
+ * - CACHE: HIT / MISS / UNKNOWN, derived from Resource Timing
+ * - NETWORK: navigator.connection.effectiveType (4g, 3g, …)
+ * - RUNTIME: WKWebView / Android WebView / Safari / Chrome / Firefox / …
+ * - SIZE: intrinsic video dimensions & aspect ratio
+ * - STATE: current readyState / paused / muted
+ */
+
+type Props = {
+ videoRef: React.RefObject;
+ atomId: string;
+ renderingTarget: RenderingTarget;
+};
+
+const overlayStyles = css`
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ z-index: 2147483647;
+ padding: 8px 10px;
+ background: ${palette('--versus-change')};
+ color: ${palette('--uk-elections-scottish-national-party')};
+ ${headlineMedium14};
+ white-space: pre;
+ pointer-events: none;
+ border-radius: 4px;
+ max-width: calc(100% - 16px);
+ overflow: hidden;
+ text-shadow: 0 0 2px ${palette('--versus-change')};
+`;
+
+const detectRuntime = (renderingTarget: RenderingTarget): string => {
+ if (typeof navigator === 'undefined') return 'SSR';
+ const ua = navigator.userAgent;
+
+ // Guardian apps render in a native WebView
+ if (renderingTarget === 'Apps') {
+ if (/iPhone|iPad|iPod/i.test(ua)) return 'WKWebView';
+ if (/Android/i.test(ua)) return 'Android WebView';
+ return 'App WebView';
+ }
+
+ // iOS detection
+ if (/iPhone|iPad|iPod/i.test(ua)) {
+ const isStandalone =
+ 'standalone' in navigator &&
+ (navigator as Navigator & { standalone?: boolean }).standalone ===
+ true;
+ if (isStandalone) return 'WKWebView (standalone)';
+ if (/CriOS/i.test(ua)) return 'Chrome iOS';
+ if (/FxiOS/i.test(ua)) return 'Firefox iOS';
+ if (/Safari/i.test(ua)) return 'Safari iOS';
+ return 'WKWebView';
+ }
+
+ if (/Edg\//i.test(ua)) return 'Edge';
+ if (/OPR\//i.test(ua)) return 'Opera';
+ if (/Firefox/i.test(ua)) return 'Firefox';
+ if (/Chrome/i.test(ua)) return 'Chrome';
+ if (/Safari/i.test(ua)) return 'Safari';
+ return 'Unknown';
+};
+
+type ConnectionLike = {
+ effectiveType?: string;
+ downlink?: number;
+ rtt?: number;
+ saveData?: boolean;
+};
+
+const getConnection = (): ConnectionLike | undefined => {
+ if (typeof navigator === 'undefined') return undefined;
+ return (navigator as Navigator & { connection?: ConnectionLike })
+ .connection;
+};
+
+const getCacheStatus = (src: string): 'HIT' | 'MISS' | 'UNKNOWN' => {
+ if (!src || typeof performance === 'undefined') return 'UNKNOWN';
+ const entries = performance.getEntriesByName(
+ src,
+ ) as PerformanceResourceTiming[];
+ if (entries.length === 0) return 'UNKNOWN';
+ const entry = entries[entries.length - 1];
+ if (!entry) return 'UNKNOWN';
+
+ // transferSize === 0 typically means a memory/disk cache hit
+ // (but can also mean a cross-origin response without TAO header).
+ if (entry.transferSize === 0 && entry.decodedBodySize > 0) return 'HIT';
+ // Heuristic: very fast response start ⇒ likely cache
+ if (entry.responseStart - entry.requestStart < 5) return 'HIT';
+ return 'MISS';
+};
+
+const getFormat = (video: HTMLVideoElement | null): string => {
+ if (!video?.currentSrc) return '—';
+ const src = video.currentSrc;
+ const ext = src.split('?')[0]?.split('#')[0]?.split('.').pop();
+ if (!ext) return 'unknown';
+ if (ext.toLowerCase() === 'm3u8') return 'HLS';
+ return ext.toUpperCase();
+};
+
+const useQueryParam = (name: string): string | null => {
+ const [value, setValue] = useState(null);
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const params = new URLSearchParams(window.location.search);
+ setValue(params.get(name));
+ }, [name]);
+ return value;
+};
+
+export const SelfHostedVideoDebugOverlay = ({
+ videoRef,
+ atomId,
+ renderingTarget,
+}: Props) => {
+ const enabled = useQueryParam('videoDebug');
+ const [, force] = useState(0);
+
+ const loadStartRef = useRef(null);
+ const startupRef = useRef(null);
+ const stallsRef = useRef(0);
+ const hasPlayedRef = useRef(false);
+
+ useEffect(() => {
+ if (enabled !== '1') return;
+ const video = videoRef.current;
+ if (!video) return;
+
+ const rerender = () => force((n) => n + 1);
+
+ // iOS Safari often fires `loadstart` before this effect attaches its
+ // listener (the overlay mounts after the