Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion dotcom-rendering/src/components/SelfHostedVideo.island.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
customSelfHostedVideoPlayAudioEventName,
customYoutubePlayEventName,
findOptimisedSourcePerMimeType,
getVideoStrategy,
} from '../lib/video';
import type { VideoStyleSettings } from '../lib/videoStyleSettings';
import { videoSettingsMap } from '../lib/videoStyleSettings';
Expand All @@ -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,
Expand Down Expand Up @@ -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%;
Expand Down Expand Up @@ -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);

/**
Expand Down Expand Up @@ -1137,6 +1143,11 @@ export const SelfHostedVideo = ({
isInteractive={videoStyleSettings.isInteractive}
isWebKitFullscreen={isWebKitFullscreen}
/>
<SelfHostedVideoDebugOverlay
videoRef={vidRef}
atomId={atomId}
renderingTarget={renderingTarget}
/>
</div>
</div>
{!!caption && format && (
Expand Down
315 changes: 315 additions & 0 deletions dotcom-rendering/src/components/SelfHostedVideoDebugOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLVideoElement>;
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<string | null>(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<number | null>(null);
const startupRef = useRef<number | null>(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 <video> element). In that
// case, seed loadStartRef from the resource timing entry's startTime
// so STARTUP can still be computed when `playing` / `timeupdate`
// eventually fires. Fall back to "now" if no entry is available.
const seedLoadStartFromResourceTiming = () => {
if (loadStartRef.current !== null) return;
const src = video.currentSrc;
if (src && typeof performance !== 'undefined') {
const entries = performance.getEntriesByName(
src,
) as PerformanceResourceTiming[];
const entry = entries[entries.length - 1];
if (entry) {
loadStartRef.current = entry.startTime;
return;
}
}
loadStartRef.current = performance.now();
};

const computeStartup = () => {
if (startupRef.current !== null) return;
if (loadStartRef.current === null) {
seedLoadStartFromResourceTiming();
}
if (loadStartRef.current !== null) {
startupRef.current = Math.max(
0,
Math.round(performance.now() - loadStartRef.current),
);
}
};

const onLoadStart = () => {
loadStartRef.current = performance.now();
startupRef.current = null;
stallsRef.current = 0;
hasPlayedRef.current = false;
rerender();
};
const onPlaying = () => {
if (!hasPlayedRef.current) {
computeStartup();
}
hasPlayedRef.current = true;
rerender();
};
// iOS Safari is unreliable about firing `playing`; the first
// `timeupdate` with currentTime > 0 is a robust fallback.
const onTimeUpdate = () => {
if (!hasPlayedRef.current && video.currentTime > 0) {
computeStartup();
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('timeupdate', onTimeUpdate);
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);

// If the video already started loading (or is already playing) before
// we attached listeners — common on iOS Safari — back-fill state now.
if (video.currentSrc || video.readyState > 0) {
seedLoadStartFromResourceTiming();
}
if (!video.paused && video.currentTime > 0) {
computeStartup();
hasPlayedRef.current = true;
}

// 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('timeupdate', onTimeUpdate);
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 <div css={overlayStyles}>{lines.join('\n')}</div>;
};
21 changes: 21 additions & 0 deletions dotcom-rendering/src/lib/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
};
Loading