[Web] Videojs - Heatmap / Most replayed parts
/ 14 min read
Updated:Table of Contents
video.js 的文件這幾年雖然有改版,但老實說還是滿難看懂的
一來範例真的不多,二來對英文不太好的人(我本人)來說,整個閱讀體驗有點像在「瞎子摸象」,每一段都看得懂字,但拼不起來在幹嘛,真的很痛苦
之前工作上遇到一些需要客製 video.js 控制項的需求,像是自訂按鈕、行為、顯示方式之類的,每次都讓我頭很痛
文件翻來翻去,看完只得到一種「好像懂了,又好像沒有」的感覺,很不踏實
還好公司裡有前輩先寫了一些客製按鈕跟比較基礎的功能,我就一邊看實際程式碼,一邊對照官方文件慢慢理解
也就是在這種「邊抄邊看、邊懷疑人生」的過程中,才逐漸抓到 video.js 客製元件大概是怎麼一回事,但!
現在要我在回過頭看,我還是需要思考一下
事件觸發
因為我們 PM 又想搞事了(開玩笑的,其實我跟 PM 關係很好,在公司裡我真心佩服的也正是我們 PM)
PM 跟我說要做一個 Heatmap,理由也很簡單:
YouTube 有,我們也該有 (我有時候會想 我做得到我就在 YouTube 上班了)
而且系統端的同事已經開始處理這塊資料了
說實話,聽到這裡我心裡第一個反應是:「只能接受了」
這大概就是我可憐的地方吧——
後端都已經開工了,我連掙扎的空間都沒有
但這種想法其實也只是玩笑話而已
畢竟小小螺絲釘,人家要我幹嘛,我還是會幹
經過這幾年職場的洗禮,我早就不是那個一心只想追新東西、或是一言不合就拒絕需求的人了
一開始公司其實是說「熱點圖」
但我真的跑去查資料後,總覺得哪裡怪怪的,相關資訊也沒有想像中多
反而是 YouTube,稱它為 Most replayed parts
想了一下,好像也說得通
本質上不是「哪裡熱」,而是「哪裡被反覆看」,有點困惑,但也沒什麼大問題
目的與成果
- 在 Video.js 的 ControlBar 上加一條「Heatmap圖疊層」,顯示影片高互動區域
- 以動態載入方式掛載元件,避免初始 bundle 變大
- 影片切換或重新載入時,確保舊 Heatmap圖會被移除再重建
資料來源與格式
Heatmap 資料由後端傳入,這部分要跟後端討論,我們共識是會傳遞跟 react-video-heatmap-track-playback-progress 文章中提到的格式差不多
// 文章中的範例大概是這樣,用比較直觀的方式表示每個區段的權重或次數:[1, 2, 1, 1, 1, 2, 4, 4, 2, 3, 3, 3, 1]
// 不過實際在我們後端實作時,考量的事情比較多一點,所以做法不太一樣// 後端會直接給一個長度為 100 的陣列,代表把整支影片切成 100 個區段來處理// 雖然表現形式不同,一個是整數、一個是浮點數,看起來好像差很多,但實際上在邏輯層面並沒有本質上的差異,只是精細度拉高而已[0.8674, 0.73655, 0.9567, 0.12, 0.44, 0.432, 0.231, ..., 0.312]元件設計要點
- Heatmap 功能是 Video.js Component(自訂 ControlBar child)
- 建議以 videojs.registerComponent(“HeatmapOverlay”, HeatmapOverlay) 註冊
- 元件內部負責把資料畫到 progress bar 上(可用 canvas 或 div bar)
在播放器(video.js)掛載流程
- 影片載入後再掛載(避免 player 未就緒)
- 先移除舊 heatmap,再重新加入新資料
實作
會有三份檔案
- VideoPlayer.tsx (video player 的元件)
- HeatmapOverlay.tsx (負責做橋接 Video.js 與 React 的元件)
- Heatmap.tsx (純 React 視覺層,負責用 Recharts 畫熱點圖,和播放進度同步更新。它不需要知道 Video.js 的元件系統,只吃資料與 player 事件)
1. 掛載 Videojs 以及懶加載 Heatmap
VideoPlayer.tsx
// 掛載 video.js 成功後,playerRef.current 注入 video playerconst playerRef = React.useRef<Player | null>(null);
// 掛載 video.js 成功後,該 value 由 false => trueconst [isVideoLoaded, setIsVideoLoaded] = useState(false);
// video.js inituseEffect(() => { // ... 省略
const player = (playerRef.current = videojs(videoRef.current, options, () => { onReady && onReady(player); setIsVideoLoaded(true); }));}, [])
// lazy load HeatmapuseEffect(() => { if (isVideoLoaded === false) return; if (!playerRef.current) return;
const removeHeatmapOverlay = () => { const controlBar = playerRef.current?.getChild("ControlBar"); if (!controlBar) return;
const heatmapOverlay = controlBar.getChild("HeatmapOverlay"); if (heatmapOverlay) { controlBar.removeChild(heatmapOverlay); } };
removeHeatmapOverlay();
// video.heat_maps 就是後端傳遞給我們的 heatmap 資料 if (!video.heat_maps || video.heat_maps.length === 0) return;
let isActive = true; const loadComponent = async () => { await import("./HeatmapOverlay");
if (!isActive) return; const controlBar = playerRef.current?.getChild("ControlBar");
if (!controlBar) return; controlBar.addChild("HeatmapOverlay", { list: numberHeatMaps, }); };
loadComponent();
return () => { isActive = false; removeHeatmapOverlay(); };}, [isVideoLoaded, numberHeatMaps, video.heat_maps]);為什麼要先 remove 再 add ?
- 影片切換或資料變動時,避免疊加多個 HeatmapOverlay
- removeChild 搭配重新 addChild,當作是一個保險安全的做法
清理與釋放資源 (這部分就不附上程式碼,可以在掛載 videojs unmount 去實作)
- 元件卸載時要移除 HeatmapOverlay
- 也要 dispose() player,避免 memory leak!
2. 純視覺圖層 (背景)
使用 Recharts,但 github 上很多選擇,可以選擇一個自己順手的
繪製的概念:
用 Recharts 畫出整體觀看分布(背景),再疊一層「目前已播放區段」的熱度(前景),並由 video.js 的播放事件驅動更新
下方圖是「背景」

這邊只會簡單放上幾段程式碼,畢竟 React 開發者大多都是自由奔放派的
這些寫法也只是「當時的我」留下來的紀錄,現在回頭看,其實自己也未必喜歡,更不用說什麼最佳解了
如果你現在看到覺得哪裡怪怪的——放心,那不是你的錯,是我以前寫的
import { AreaChart, Area, ResponsiveContainer } from "recharts";
const CHART_CONTAINER_STYLES = "absolute left-[50%] bottom-[-13px] z-[-1] h-8 w-[calc(100%-16px)] translate-x-[-50%] px-0 lg:bottom-[56px]";
// Gradient definitions for heatmap visualizationconst GRADIENT_DEFINITIONS = { unplayed: { id: "unColorPlayed", color: "#ffffff", opacity: 0.5, }, played: { id: "colorPlayed", color: "#ff6287", opacity: 0.5, },} as const;
const GradientDefinition = ({ definition }: { definition: typeof GRADIENT_DEFINITIONS.played | typeof GRADIENT_DEFINITIONS.unplayed}) => ( <linearGradient id={definition.id} x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor={definition.color} stopOpacity={definition.opacity} /> <stop offset="95%" stopColor={definition.color} stopOpacity={definition.opacity} /> </linearGradient>);
const Heatmap = ({ viewCountData: number[]; player: videojs.Player; }) => { // Memoized total playback data // 預設狀態:無資料時顯示 100 個空點,確保圖表不因空陣列而異常 // Area 元件相容:views 是為了要符合 Recharts <Area /> 的 dataKey 綁定需求 const totalPlaybackData = useMemo(() => { if (!viewCountData.length) { return Array(100).fill(null).map((_, index) => ({ name: index, views: 0, })); }
return viewCountData.map((viewCount: number, index: number) => ({ name: index, views: viewCount || 0, })); }, [viewCountData]);
return ( <> <div className={CHART_CONTAINER_STYLES}> <ResponsiveContainer width="100%" height={32} minHeight={32}> <AreaChart data={totalPlaybackData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}> <defs> <GradientDefinition definition={GRADIENT_DEFINITIONS.unplayed} /> </defs> <Area type="basis" dataKey="views" stroke="none" fillOpacity={0.5} fill={`url(#${GRADIENT_DEFINITIONS.unplayed.id})`} isAnimationActive={false} /> </AreaChart> </ResponsiveContainer> </div> </> )}上方基本上完成了「背景」
3. 純視覺圖層 (前景)
這裡的複雜度主要來自時間軸的變化,必須同步驅動前景畫面的更新
// Heatmap component
(省略...)
// Current playback data stateconst [currentPlaybackData, setCurrentPlaybackData] = useState<{ name: number; views: number;}[]>([]);const [currentProgress, setCurrentProgress] = useState<number>(0);
// Optimized position update functionconst updateCurrentPosition = useCallback(() => { if (!player || !player.duration || player.duration() <= 0) { return; }
const currentTime = player.currentTime(); const duration = player.duration();
// Cache duration to avoid repeated calls if (durationRef.current !== duration) { durationRef.current = duration; }
// Calculate current progress percentage const progress = Math.min((currentTime / duration) * 100, 100); setCurrentProgress(progress);
// Calculate current segment based on total data length const totalSegments = totalPlaybackData.length; if (totalSegments === 0) return;
const timePerSegment = duration / totalSegments; const currentSegment = Math.min( Math.floor(currentTime / timePerSegment), totalSegments - 1 );
// Only update if segment changed to reduce unnecessary renders if (currentSegmentRef.current !== currentSegment) { currentSegmentRef.current = currentSegment;
const newCurrentPlaybackData = totalPlaybackData .slice(0, currentSegment + 1) .map((item) => ({ ...item }));
setCurrentPlaybackData(newCurrentPlaybackData); }}, [totalPlaybackData, player]);
const debouncedUpdate = useMemo( () => createDebouncedUpdate(updateCurrentPosition), [updateCurrentPosition]);
useEffect(() => { if (!player) return;
// Initial update updateCurrentPosition();
// Use debounced update for timeupdate to improve performance const handleTimeUpdate = debouncedUpdate; const handleSeeked = updateCurrentPosition; // Immediate update for seeking
player.on("timeupdate", handleTimeUpdate); player.on("seeked", handleSeeked); player.on("loadedmetadata", updateCurrentPosition);
return () => { player.off("timeupdate", handleTimeUpdate); player.off("seeked", handleSeeked); player.off("loadedmetadata", updateCurrentPosition);
// Cancel any pending debounced calls debouncedUpdate.cancel(); };}, [player, debouncedUpdate, updateCurrentPosition]);
return ( <> <背景 />
{currentPlaybackData.length > 0 && currentProgress > 0 && ( <div className={CHART_CONTAINER_STYLES}> <HeatmapAreaChart data={currentPlaybackData} gradientId={GRADIENT_DEFINITIONS.played.id} width={`${currentProgress}%`} /> <svg width="0" height="0"> <defs> <GradientDefinition definition={GRADIENT_DEFINITIONS.played} /> </defs> </svg> </div> )} </>)// Reusable area chart componentconst HeatmapAreaChart = ({ data, gradientId, width = "100%"}: { data: { name: number; views: number; }[]; gradientId: string; width?: string | number;}) => ( <ResponsiveContainer width={width} height={32} minHeight={32}> <AreaChart data={data} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}> <Area type="basis" dataKey="views" stroke="none" fillOpacity={0.5} fill={`url(#${gradientId})`} isAnimationActive={false} /> </AreaChart> </ResponsiveContainer>);上述大概就是前景的程式碼,我不是很滿意 但也不想再去修改它了!
updateCurrentPosition 主要負責把播放器目前的時間轉成「播放進度」與「已播放區段資料」,再更新畫面
- 取得時間:讀 currentTime() 與 duration()
- 快取長度:若 duration 變動就更新 durationRef(避免重複呼叫)
- 計算進度:progress = (currentTime / duration) * 100,上限 100,存進 currentProgress
- 段落計算:用 totalPlaybackData.length 當總段數,算出 timePerSegment
- 目前段落:currentSegment = Math.min(Math.floor(currentTime / timePerSegment), totalSegments - 1),並限制在最大段索引
- 變更檢查:若段落沒變(currentSegmentRef),就不更新 state
- 更新已播放資料:totalPlaybackData.slice(0, currentSegment + 1) 做出「已播放區段」,再 setCurrentPlaybackData
背景跟前景完成,就剩下最後的橋接部分
4. 橋接元件
到這裡我累了,程式碼的部分也沒什特別需說明的
import React from "react";import videojs from "video.js";import { createRoot, Root } from "react-dom/client";import type HeatmapComponent from "./Heatmap";
// Define proper typesinterface HeatmapOverlayOptions extends videojs.ComponentOptions { list: number[]; // View count data}
const VjsComponent = videojs.getComponent("Component");
class HeatmapOverlay extends VjsComponent { private _root: Root | null = null; private _viewCountData: number[]; private _player: videojs.Player; private _isDisposed: boolean = false; private _HeatmapComponent: typeof HeatmapComponent | null = null;
constructor(player: videojs.Player, options: HeatmapOverlayOptions) { super(player, options);
this._player = player; this._viewCountData = options.list || [];
// Validate input data if (!Array.isArray(this._viewCountData)) { console.warn('HeatmapOverlay: Invalid viewCountData provided'); this._viewCountData = []; }
this.initializeComponent(); }
private initializeComponent() { try { const root = createRoot(this.el()); this._root = root;
// Bind methods this.mountReactComponent = this.mountReactComponent.bind(this);
// Mount when player is ready this._player.ready(() => { if (!this._isDisposed) { this.mountReactComponent(); } });
// Cleanup on dispose this.on("dispose", this.cleanup.bind(this)); } catch (error) { console.error('Failed to initialize HeatmapOverlay:', error); } }
createEl(): Element { return videojs.dom.createEl("div", { className: "vjs-custom-overlay", }); }
mountReactComponent(): void { if (!this._root || this._isDisposed) { return; }
const renderHeatmap = async () => { try { if (!this._HeatmapComponent) { const module = await import("./Heatmap"); this._HeatmapComponent = module.default; }
if (this._isDisposed || !this._HeatmapComponent) { return; }
const Heatmap = this._HeatmapComponent;
this._root?.render( <Heatmap viewCountData={this._viewCountData} player={this._player} /> ); } catch (error) { console.error("Failed to mount Heatmap component:", error); } };
renderHeatmap(); }
private cleanup(): void { this._isDisposed = true; if (this._root) { try { this._root.unmount(); } catch (error) { console.error('Failed to unmount React component:', error); } this._root = null; } }}
// Register the component with Video.jsvideojs.registerComponent("HeatmapOverlay", HeatmapOverlay);
export default HeatmapOverlay;最後
這篇其實更像是寫給未來自己的備忘錄
當下的寫法可能不完美,但至少記下了當時是怎麼思考、怎麼一步步把功能拼起來的
如果哪天又得回來碰 video.js,希望這篇文章能提醒我:「啊對,我以前也是這樣搞定的」