From c187546d95a2409da05afbcc328f9f4082db1906 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Tue, 19 May 2026 10:39:57 +0100 Subject: [PATCH 1/4] Create query param to switch between video formats --- .../src/components/SelfHostedVideo.island.tsx | 7 ++++++- dotcom-rendering/src/lib/video.ts | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index 22cbc3e385b..110156ae089 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'; @@ -144,6 +145,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 +652,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); /** diff --git a/dotcom-rendering/src/lib/video.ts b/dotcom-rendering/src/lib/video.ts index 3ecdb55bf6a..4406efc23c8 100644 --- a/dotcom-rendering/src/lib/video.ts +++ b/dotcom-rendering/src/lib/video.ts @@ -177,3 +177,24 @@ export const formatTimeForDisplay = (timeInSeconds: number): string => { return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; + +type VideoStrategy = + | 'video/mp4' + | 'application/vnd.apple.mpegurl' + | 'application/x-mpegURL'; + +export const getVideoStrategy = (): VideoStrategy => { + const param = new URLSearchParams(location.search).get('videoStrategy'); + + switch (param) { + case 'mp4': + return 'video/mp4'; + case 'hls': + return 'application/vnd.apple.mpegurl'; + + case 'hls-alt': + return 'application/x-mpegURL'; + default: + return 'video/mp4'; + } +}; From 33b6d40cf491632c86d3e4b24415bd4182abdf23 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Tue, 19 May 2026 11:12:33 +0100 Subject: [PATCH 2/4] Add a debug layer --- .../src/components/SelfHostedVideo.island.tsx | 6 + .../SelfHostedVideoDebugOverlay.tsx | 266 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index 110156ae089..d51df1b1fbb 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -32,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, @@ -1142,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..1457b20ae54 --- /dev/null +++ b/dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx @@ -0,0 +1,266 @@ +import { css } from '@emotion/react'; +import { useEffect, useRef, useState } from 'react'; +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: rgba(0, 0, 0, 0.75); + color: #0f0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.35; + white-space: pre; + pointer-events: none; + border-radius: 4px; + max-width: calc(100% - 16px); + overflow: hidden; + text-shadow: 0 0 2px #000; +`; + +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); + + const onLoadStart = () => { + loadStartRef.current = performance.now(); + startupRef.current = null; + stallsRef.current = 0; + hasPlayedRef.current = false; + rerender(); + }; + const onPlaying = () => { + if ( + !hasPlayedRef.current && + loadStartRef.current !== null && + startupRef.current === null + ) { + startupRef.current = Math.round( + performance.now() - loadStartRef.current, + ); + } + hasPlayedRef.current = true; + rerender(); + }; + const onStallish = () => { + if (hasPlayedRef.current) { + stallsRef.current += 1; + rerender(); + } + }; + const passthrough = () => rerender(); + + video.addEventListener('loadstart', onLoadStart); + video.addEventListener('playing', onPlaying); + video.addEventListener('waiting', onStallish); + video.addEventListener('stalled', onStallish); + video.addEventListener('loadedmetadata', passthrough); + video.addEventListener('canplay', passthrough); + video.addEventListener('pause', passthrough); + video.addEventListener('ended', passthrough); + video.addEventListener('ratechange', passthrough); + video.addEventListener('volumechange', passthrough); + + // Re-render every second so connection / cache snapshots stay fresh + const interval = window.setInterval(rerender, 1000); + + return () => { + video.removeEventListener('loadstart', onLoadStart); + video.removeEventListener('playing', onPlaying); + video.removeEventListener('waiting', onStallish); + video.removeEventListener('stalled', onStallish); + video.removeEventListener('loadedmetadata', passthrough); + video.removeEventListener('canplay', passthrough); + video.removeEventListener('pause', passthrough); + video.removeEventListener('ended', passthrough); + video.removeEventListener('ratechange', passthrough); + video.removeEventListener('volumechange', passthrough); + window.clearInterval(interval); + }; + }, [enabled, videoRef]); + + if (enabled !== '1') return null; + + const video = videoRef.current; + const connection = getConnection(); + const runtime = detectRuntime(renderingTarget); + const format = getFormat(video); + const cache = video?.currentSrc + ? getCacheStatus(video.currentSrc) + : 'UNKNOWN'; + const startup = + startupRef.current !== null ? `${startupRef.current}ms` : '—'; + const stalls = stallsRef.current; + const network = connection?.effectiveType ?? 'unknown'; + const downlink = + connection?.downlink !== undefined ? `${connection.downlink}Mbps` : '—'; + const rtt = connection?.rtt !== undefined ? `${connection.rtt}ms` : '—'; + const saveData = connection?.saveData ? ' (saveData)' : ''; + + const size = video + ? `${video.videoWidth}x${video.videoHeight} (${ + video.videoHeight > 0 + ? (video.videoWidth / video.videoHeight).toFixed(2) + : '—' + })` + : '—'; + + const readyState = (() => { + switch (video?.readyState) { + case 0: + return 'HAVE_NOTHING'; + case 1: + return 'HAVE_METADATA'; + case 2: + return 'HAVE_CURRENT_DATA'; + case 3: + return 'HAVE_FUTURE_DATA'; + case 4: + return 'HAVE_ENOUGH_DATA'; + default: + return '—'; + } + })(); + + const state = video + ? `${readyState}${video.paused ? ' · paused' : ' · playing'}${ + video.muted ? ' · muted' : '' + }` + : '—'; + + const lines = [ + `ATOM: ${atomId}`, + `FORMAT: ${format}`, + `STARTUP: ${startup}`, + `STALLS: ${stalls}`, + `CACHE: ${cache}`, + `NETWORK: ${network} · ${downlink} · rtt ${rtt}${saveData}`, + `RUNTIME: ${runtime}`, + `SIZE: ${size}`, + `STATE: ${state}`, + ]; + + return
{lines.join('\n')}
; +}; From e0f7e7aea6a8399022b71f8c2bea165ead082a5e Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Tue, 19 May 2026 17:09:10 +0100 Subject: [PATCH 3/4] se source --- .../src/components/SelfHostedVideoDebugOverlay.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx b/dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx index 1457b20ae54..333a91d8402 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx @@ -1,5 +1,7 @@ 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'; /** @@ -30,17 +32,15 @@ const overlayStyles = css` left: 8px; z-index: 2147483647; padding: 8px 10px; - background: rgba(0, 0, 0, 0.75); - color: #0f0; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 11px; - line-height: 1.35; + 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 #000; + text-shadow: 0 0 2px ${palette('--versus-change')}; `; const detectRuntime = (renderingTarget: RenderingTarget): string => { From 3e5246aa4121b58d455582012da36ce8926cf50d Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Wed, 20 May 2026 15:43:16 +0100 Subject: [PATCH 4/4] Fix startup not populating on iOS Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay mounts after the