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