Skip to content

Commit dcc71f3

Browse files
authored
chore: add HEVC codec support detection and user notification (#375)
1 parent 2b59e5d commit dcc71f3

3 files changed

Lines changed: 102 additions & 1 deletion

File tree

components/clips/ClipPlayer.vue

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import {
88
Volume2,
99
VolumeX,
1010
} from "lucide-vue-next";
11+
import { useI18n } from "vue-i18n";
1112
import StreamCanvas from "~/components/match/StreamCanvas.vue";
13+
import {
14+
browserSupportsHevc,
15+
notifyMissingHevcOnce,
16+
} from "~/utilities/hevcSupport";
17+
18+
const { t } = useI18n();
1219
1320
// Shared video surface for clips — used by the inline highlights reel
1421
// (featured clip) and the clip detail modal. Encapsulates: custom
@@ -180,6 +187,25 @@ function startProgressLoop() {
180187
progressRafId = requestAnimationFrame(tickProgress);
181188
}
182189
190+
// Browsers surface a missing HEVC decoder as either MEDIA_ERR_SRC_NOT_SUPPORTED
191+
// or MEDIA_ERR_DECODE on the <video> element. We can't tell a clip's codec
192+
// before it loads, so we only warn after a failure — and only when the browser
193+
// itself can't decode H.265 — to avoid noise on H.264 clips that fail for
194+
// unrelated reasons (network, expired URL, etc).
195+
function onVideoError(event: Event) {
196+
const video = event.target as HTMLVideoElement | null;
197+
const code = video?.error?.code;
198+
if (code !== MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED && code !== MediaError.MEDIA_ERR_DECODE) {
199+
return;
200+
}
201+
if (browserSupportsHevc()) return;
202+
notifyMissingHevcOnce({
203+
title: t("toasts.hevc_missing_title"),
204+
body: t("toasts.hevc_missing_body"),
205+
linkLabel: t("toasts.hevc_missing_link"),
206+
});
207+
}
208+
183209
async function tryPlay(video: HTMLVideoElement) {
184210
video.muted = muted.value;
185211
try {
@@ -443,6 +469,7 @@ defineExpose({ play, pause, toggle, videoEl: videoRef, isFullscreen });
443469
"
444470
@webkitbeginfullscreen="onVideoWebkitBeginFullscreen"
445471
@webkitendfullscreen="onVideoWebkitEndFullscreen"
472+
@error="onVideoError"
446473
@click="toggle"
447474
/>
448475
<slot v-else name="empty" />

i18n/locales/en.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@
3333
"set_winner_failed_description": "Selected lineup id is missing for this match.",
3434
"match_reset_applied": "Tournament match reset applied",
3535
"match_reset_failed": "Unable to reset tournament match",
36-
"please_try_again": "Please try again."
36+
"please_try_again": "Please try again.",
37+
"hevc_missing_title": "Can't play this clip",
38+
"hevc_missing_body": "This video is encoded with HEVC (H.265). Your browser can't decode it — install the HEVC Video Extensions on Windows to enable playback:",
39+
"hevc_missing_link": "Get the HEVC Video Extensions"
3740
},
3841
"shortcut_groups": {
3942
"playback": "Playback",

utilities/hevcSupport.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { h } from "vue";
2+
import { toast } from "~/components/ui/toast/use-toast";
3+
4+
// Microsoft Store listing for the HEVC Video Extensions. Most Windows
5+
// Chromium browsers can't decode H.265 clips without this installed.
6+
const HEVC_PLUGIN_URL =
7+
"https://apps.microsoft.com/detail/9nmzlz57r3t7";
8+
9+
let cachedSupport: boolean | null = null;
10+
let warnedThisSession = false;
11+
12+
export function browserSupportsHevc(): boolean {
13+
if (cachedSupport !== null) return cachedSupport;
14+
if (typeof document === "undefined") {
15+
cachedSupport = true;
16+
return cachedSupport;
17+
}
18+
19+
const video = document.createElement("video");
20+
const canPlay =
21+
video.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"') ||
22+
video.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"') ||
23+
video.canPlayType('video/mp4; codecs="hvc1"');
24+
if (canPlay) {
25+
cachedSupport = true;
26+
return cachedSupport;
27+
}
28+
29+
const ms = (
30+
window as unknown as { MediaSource?: { isTypeSupported(t: string): boolean } }
31+
).MediaSource;
32+
if (
33+
ms &&
34+
(ms.isTypeSupported('video/mp4; codecs="hvc1.1.6.L93.B0"') ||
35+
ms.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"'))
36+
) {
37+
cachedSupport = true;
38+
return cachedSupport;
39+
}
40+
41+
cachedSupport = false;
42+
return cachedSupport;
43+
}
44+
45+
export function notifyMissingHevcOnce(strings: {
46+
title: string;
47+
body: string;
48+
linkLabel: string;
49+
}): void {
50+
if (warnedThisSession) return;
51+
warnedThisSession = true;
52+
53+
toast({
54+
title: strings.title,
55+
description: h("span", { class: "select-text break-words" }, [
56+
`${strings.body} `,
57+
h(
58+
"a",
59+
{
60+
href: HEVC_PLUGIN_URL,
61+
target: "_blank",
62+
rel: "noopener noreferrer",
63+
class:
64+
"underline underline-offset-2 font-medium hover:opacity-80",
65+
},
66+
strings.linkLabel,
67+
),
68+
]),
69+
variant: "destructive",
70+
});
71+
}

0 commit comments

Comments
 (0)