skip to content
BlogZzz

[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 上班了)

而且系統端的同事已經開始處理這塊資料了

說實話,聽到這裡我心裡第一個反應是:「只能接受了」

這大概就是我可憐的地方吧——

後端都已經開工了,我連掙扎的空間都沒有

但這種想法其實也只是玩笑話而已

fk-u

畢竟小小螺絲釘,人家要我幹嘛,我還是會幹

經過這幾年職場的洗禮,我早就不是那個一心只想追新東西、或是一言不合就拒絕需求的人了

一開始公司其實是說「熱點圖」

但我真的跑去查資料後,總覺得哪裡怪怪的,相關資訊也沒有想像中多

反而是 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 player
const playerRef = React.useRef<Player | null>(null);
// 掛載 video.js 成功後,該 value 由 false => true
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
// video.js init
useEffect(() => {
// ... 省略
const player = (playerRef.current = videojs(videoRef.current, options, () => {
onReady && onReady(player);
setIsVideoLoaded(true);
}));
}, [])
// lazy load Heatmap
useEffect(() => {
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 visualization
const 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 state
const [currentPlaybackData, setCurrentPlaybackData] = useState<{
name: number;
views: number;
}[]>([]);
const [currentProgress, setCurrentProgress] = useState<number>(0);
// Optimized position update function
const 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 component
const 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. 橋接元件

到這裡我累了,程式碼的部分也沒什特別需說明的

fake frontend
import React from "react";
import videojs from "video.js";
import { createRoot, Root } from "react-dom/client";
import type HeatmapComponent from "./Heatmap";
// Define proper types
interface 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.js
videojs.registerComponent("HeatmapOverlay", HeatmapOverlay);
export default HeatmapOverlay;

最後

這篇其實更像是寫給未來自己的備忘錄

當下的寫法可能不完美,但至少記下了當時是怎麼思考、怎麼一步步把功能拼起來的

如果哪天又得回來碰 video.js,希望這篇文章能提醒我:「啊對,我以前也是這樣搞定的」

haha

參考

video.js Docs

react-video-heatmap-track-playback-progress