diff --git a/apps/resources/src/components/NewVideoContentPage/VideoCarousel/VideoCard/VideoCard.tsx b/apps/resources/src/components/NewVideoContentPage/VideoCarousel/VideoCard/VideoCard.tsx
index df7cea12966..13354cf7a68 100644
--- a/apps/resources/src/components/NewVideoContentPage/VideoCarousel/VideoCard/VideoCard.tsx
+++ b/apps/resources/src/components/NewVideoContentPage/VideoCarousel/VideoCard/VideoCard.tsx
@@ -56,8 +56,8 @@ export function VideoCard({
{
})
})
+ it('does not read volume from a disposed player', async () => {
+ player.dispose()
+
+ expect(() =>
+ render(
+
+
+
+
+
+
+
+
+
+ )
+ ).not.toThrow()
+ })
+
it('updates volume on volumechange', async () => {
vi.spyOn(player, 'volume').mockReturnValue(0)
diff --git a/apps/resources/src/components/VideoContentPage/VideoHero/VideoPlayer/VideoControls/VideoControls.tsx b/apps/resources/src/components/VideoContentPage/VideoHero/VideoPlayer/VideoControls/VideoControls.tsx
index 9c93300e1e9..ba817fc81e9 100644
--- a/apps/resources/src/components/VideoContentPage/VideoHero/VideoPlayer/VideoControls/VideoControls.tsx
+++ b/apps/resources/src/components/VideoContentPage/VideoHero/VideoPlayer/VideoControls/VideoControls.tsx
@@ -57,6 +57,14 @@ interface VideoControlProps {
onVisibleChanged?: (active: boolean) => void
}
+function isPlayerReady(player?: Player): player is Player {
+ return player != null && !player.isDisposed()
+}
+
+function getPlayerVolume(player?: Player): number {
+ return isPlayerReady(player) ? (player.volume() ?? 1) * 100 : 100
+}
+
function evtToDataLayer(
eventType,
mcId,
@@ -220,7 +228,7 @@ export function VideoControls({
useEffect(() => {
dispatchPlayer({
type: 'SetVolume',
- volume: (player?.volume() ?? 1) * 100
+ volume: getPlayerVolume(player)
})
player?.on('play', () => {
if ((player?.currentTime() ?? 0) < 0.02) {
@@ -290,17 +298,19 @@ export function VideoControls({
player?.on('volumechange', () => {
dispatchPlayer({
type: 'SetMute',
- mute: player?.muted() ?? false
+ mute: isPlayerReady(player) ? (player.muted() ?? false) : false
})
dispatchPlayer({
type: 'SetVolume',
- volume: (player?.volume() ?? 1) * 100
+ volume: getPlayerVolume(player)
})
})
player?.on('fullscreenchange', () => {
dispatchPlayer({
type: 'SetFullscreen',
- fullscreen: player?.isFullscreen() ?? false
+ fullscreen: isPlayerReady(player)
+ ? (player.isFullscreen() ?? false)
+ : false
})
})
player?.on('useractive', () =>
@@ -391,9 +401,9 @@ export function VideoControls({
function handlePlay(): void {
if (!play) {
- void player?.play()
+ if (isPlayerReady(player)) void player.play()
} else {
- void player?.pause()
+ if (isPlayerReady(player)) void player.pause()
}
}
@@ -406,7 +416,7 @@ export function VideoControls({
})
} else {
if (isMobile()) {
- void player?.requestFullscreen()
+ if (isPlayerReady(player)) void player.requestFullscreen()
dispatchPlayer({
type: 'SetFullscreen',
fullscreen: true
@@ -427,12 +437,12 @@ export function VideoControls({
type: 'SetProgress',
progress: value
})
- player?.currentTime(value)
+ if (isPlayerReady(player)) player.currentTime(value)
}
}
function handleMute(): void {
- player?.muted(!mute)
+ if (isPlayerReady(player)) player.muted(!mute)
dispatchPlayer({
type: 'SetMute',
mute: !mute
@@ -446,7 +456,7 @@ export function VideoControls({
type: 'SetVolume',
volume: value
})
- player?.volume(value / 100)
+ if (isPlayerReady(player)) player.volume(value / 100)
}
}
@@ -494,7 +504,7 @@ export function VideoControls({
onClick={getClickHandler(handlePlay, () => {
void handleFullscreen()
})}
- onMouseMove={() => player?.userActive(true)}
+ onMouseMove={() => isPlayerReady(player) && player.userActive(true)}
data-testid="VideoControls"
>
{!loading ? (
@@ -532,7 +542,7 @@ export function VideoControls({
{images[0]?.mobileCinematicHigh != null && (
{
@@ -46,6 +47,26 @@ describe('VideoControls', () => {
wasUnmuted: true
}
+ it('does not read volume from a disposed player', () => {
+ const disposedPlayer = {
+ ...mockPlayer,
+ isDisposed: jest.fn().mockReturnValue(true),
+ volume: jest.fn(() => {
+ throw new Error('disposed')
+ })
+ } as unknown as Player
+
+ expect(() =>
+ render(
+
+
+
+
+
+ )
+ ).not.toThrow()
+ })
+
it('should render video controls', () => {
render(
diff --git a/apps/watch/src/components/VideoBlock/VideoBlockPlayer/VideoControls/VideoControls.tsx b/apps/watch/src/components/VideoBlock/VideoBlockPlayer/VideoControls/VideoControls.tsx
index 4c234b4c667..3d3afc45e9a 100644
--- a/apps/watch/src/components/VideoBlock/VideoBlockPlayer/VideoControls/VideoControls.tsx
+++ b/apps/watch/src/components/VideoBlock/VideoBlockPlayer/VideoControls/VideoControls.tsx
@@ -71,6 +71,25 @@ interface VideoControlProps {
wasUnmuted?: boolean
}
+function isPlayerReady(player?: Player): player is Player {
+ return (
+ player != null &&
+ (typeof player.isDisposed !== 'function' || !player.isDisposed())
+ )
+}
+
+function getPlayerCurrentTime(player?: Player): number {
+ return isPlayerReady(player) ? (player.currentTime() ?? 0) : 0
+}
+
+function getPlayerDuration(player?: Player): number {
+ return isPlayerReady(player) ? (player.duration() ?? 1) : 1
+}
+
+function getPlayerVolume(player?: Player): number {
+ return isPlayerReady(player) ? (player.volume() ?? 1) * 100 : 100
+}
+
function evtToDataLayer(
eventType,
mcId,
@@ -185,7 +204,9 @@ export function VideoControls({
let retryTimeout: NodeJS.Timeout | undefined
const updateDuration = (state: string): void => {
- const playerDuration = player?.duration()
+ const playerDuration = isPlayerReady(player)
+ ? player.duration()
+ : undefined
if (
playerDuration != null &&
@@ -248,7 +269,7 @@ export function VideoControls({
useEffect(() => {
const percent =
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
if (percent > progressPercentNotYetEmitted[0]) {
eventToDataLayer(
@@ -258,7 +279,7 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
percent
)
const [, ...rest] = progressPercentNotYetEmitted
@@ -278,7 +299,7 @@ export function VideoControls({
// Define stable event handlers using useCallback
const handlePlay = useCallback(() => {
- if ((player?.currentTime() ?? 0) < 0.02) {
+ if (getPlayerCurrentTime(player) < 0.02) {
eventToDataLayer(
'video_start',
id,
@@ -286,9 +307,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
)
} else {
@@ -299,13 +320,13 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
)
}
- void player?.play()
+ if (isPlayerReady(player)) void player.play()
dispatchPlayer({
type: 'SetPlay',
play: true
@@ -313,7 +334,7 @@ export function VideoControls({
}, [player, id, variant, title, durationSeconds, dispatchPlayer])
const handlePause = useCallback(() => {
- if ((player?.currentTime() ?? 0) > 0.02) {
+ if (getPlayerCurrentTime(player) > 0.02) {
eventToDataLayer(
'video_pause',
id,
@@ -321,13 +342,13 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
)
}
- player?.pause()
+ if (isPlayerReady(player)) player.pause()
dispatchPlayer({
type: 'SetPlay',
play: false
@@ -342,7 +363,7 @@ export function VideoControls({
play: true
})
// Analytics for player events
- if ((player?.currentTime() ?? 0) < 0.02) {
+ if (getPlayerCurrentTime(player) < 0.02) {
eventToDataLayer(
'video_start',
id,
@@ -350,9 +371,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
)
} else {
@@ -363,16 +384,16 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
)
}
}, [player, id, variant, title, durationSeconds, dispatchPlayer])
const handlePlayerEventPause = useCallback(() => {
- if (!(player?.paused() ?? false)) {
+ if (!isPlayerReady(player) || !(player.paused() ?? false)) {
// Firefox occasionally emits pause events even though playback continues;
// ignore them so we don't thrash global player state.
return
@@ -383,7 +404,7 @@ export function VideoControls({
play: false
})
// Analytics for player events
- if ((player?.currentTime() ?? 0) > 0.02) {
+ if (getPlayerCurrentTime(player) > 0.02) {
eventToDataLayer(
'video_pause',
id,
@@ -391,9 +412,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
)
}
@@ -402,7 +423,7 @@ export function VideoControls({
const handleTimeUpdate = useCallback(() => {
dispatchPlayer({
type: 'SetCurrentTime',
- currentTime: secondsToTimeFormat(player?.currentTime() ?? 0, {
+ currentTime: secondsToTimeFormat(getPlayerCurrentTime(player), {
trimZeroes: true
})
})
@@ -410,7 +431,7 @@ export function VideoControls({
type: 'SetProgress',
progress:
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
})
}, [player, durationSeconds, dispatchPlayer])
@@ -418,18 +439,20 @@ export function VideoControls({
const handleVolumeChange = useCallback(() => {
dispatchPlayer({
type: 'SetMute',
- mute: player?.muted() ?? false
+ mute: isPlayerReady(player) ? (player.muted() ?? false) : false
})
dispatchPlayer({
type: 'SetVolume',
- volume: (player?.volume() ?? 1) * 100
+ volume: getPlayerVolume(player)
})
}, [player, dispatchPlayer])
const handleFullscreenChange = useCallback(() => {
dispatchPlayer({
type: 'SetFullscreen',
- fullscreen: player?.isFullscreen() ?? false
+ fullscreen: isPlayerReady(player)
+ ? (player.isFullscreen() ?? false)
+ : false
})
}, [player, dispatchPlayer])
@@ -483,9 +506,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
)
}, [player, id, variant, title, durationSeconds])
@@ -523,7 +546,7 @@ export function VideoControls({
useEffect(() => {
dispatchPlayer({
type: 'SetVolume',
- volume: (player?.volume() ?? 1) * 100
+ volume: getPlayerVolume(player)
})
// Attach handlers - use separate handlers for player events vs user interactions
@@ -550,9 +573,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
)
} else {
@@ -563,9 +586,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
durationSeconds > 0
- ? Math.round(((player?.currentTime() ?? 0) / durationSeconds) * 100)
+ ? Math.round((getPlayerCurrentTime(player) / durationSeconds) * 100)
: 0
)
}
@@ -630,7 +653,7 @@ export function VideoControls({
})
} else {
if (isMobile()) {
- void player?.requestFullscreen()
+ if (isPlayerReady(player)) void player.requestFullscreen()
dispatchPlayer({
type: 'SetFullscreen',
fullscreen: true
@@ -663,7 +686,7 @@ export function VideoControls({
type: 'SetProgress',
progress: progressPercent
})
- player?.currentTime(timeInSeconds)
+ if (isPlayerReady(player)) player.currentTime(timeInSeconds)
}
}
@@ -675,7 +698,7 @@ export function VideoControls({
function handleMute(): void {
const newMuteState = !mute
- player?.muted(newMuteState)
+ if (isPlayerReady(player)) player.muted(newMuteState)
dispatchPlayer({
type: 'SetMute',
mute: newMuteState
@@ -692,7 +715,7 @@ export function VideoControls({
type: 'SetVolume',
volume: value
})
- player?.volume(value / 100)
+ if (isPlayerReady(player)) player.volume(value / 100)
}
}
@@ -738,7 +761,7 @@ export function VideoControls({
onClick={getClickHandler(handlePlay, () => {
void handleFullscreen()
})}
- onMouseMove={() => player?.userActive(true)}
+ onMouseMove={() => isPlayerReady(player) && player.userActive(true)}
data-testid="VideoControls"
>
{/* Show title overlay only once on a single page */}
@@ -780,7 +803,7 @@ export function VideoControls({
{images[0]?.mobileCinematicHigh != null && (
void
}
+function isPlayerReady(player?: Player): player is Player {
+ return (
+ player != null &&
+ (typeof player.isDisposed !== 'function' || !player.isDisposed())
+ )
+}
+
+function getPlayerCurrentTime(player?: Player): number {
+ return isPlayerReady(player) ? (player.currentTime() ?? 0) : 0
+}
+
+function getPlayerDuration(player?: Player): number {
+ return isPlayerReady(player) ? (player.duration() ?? 1) : 1
+}
+
+function getPlayerVolume(player?: Player): number {
+ return isPlayerReady(player) ? (player.volume() ?? 1) * 100 : 100
+}
+
function evtToDataLayer(
eventType,
mcId,
@@ -130,7 +149,9 @@ export function VideoControls({
let retryTimeout: NodeJS.Timeout | undefined
const updateDuration = (state: string): void => {
- const playerDuration = player?.duration()
+ const playerDuration = isPlayerReady(player)
+ ? player.duration()
+ : undefined
if (
playerDuration != null &&
@@ -196,7 +217,7 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
Math.round((progress / durationSeconds) * 100)
)
const [, ...rest] = progressPercentNotYetEmitted
@@ -218,10 +239,10 @@ export function VideoControls({
useEffect(() => {
dispatchPlayer({
type: 'SetVolume',
- volume: (player?.volume() ?? 1) * 100
+ volume: getPlayerVolume(player)
})
player?.on('play', () => {
- if ((player?.currentTime() ?? 0) < 0.02) {
+ if (getPlayerCurrentTime(player) < 0.02) {
eventToDataLayer(
'video_start',
id,
@@ -229,9 +250,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
Math.round(
- ((player?.currentTime() ?? 0) / (player?.duration() ?? 1)) * 100
+ (getPlayerCurrentTime(player) / getPlayerDuration(player)) * 100
)
)
} else {
@@ -242,9 +263,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
Math.round(
- ((player?.currentTime() ?? 0) / (player?.duration() ?? 1)) * 100
+ (getPlayerCurrentTime(player) / getPlayerDuration(player)) * 100
)
)
}
@@ -254,7 +275,7 @@ export function VideoControls({
})
})
player?.on('pause', () => {
- if ((player?.currentTime() ?? 0) > 0.02) {
+ if (getPlayerCurrentTime(player) > 0.02) {
eventToDataLayer(
'video_pause',
id,
@@ -262,9 +283,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
Math.round(
- ((player?.currentTime() ?? 0) / (player?.duration() ?? 1)) * 100
+ (getPlayerCurrentTime(player) / getPlayerDuration(player)) * 100
)
)
}
@@ -276,29 +297,31 @@ export function VideoControls({
player?.on('timeupdate', () => {
dispatchPlayer({
type: 'SetCurrentTime',
- currentTime: secondsToTimeFormat(player?.currentTime() ?? 0, {
+ currentTime: secondsToTimeFormat(getPlayerCurrentTime(player), {
trimZeroes: true
})
})
dispatchPlayer({
type: 'SetProgress',
- progress: Math.round(player?.currentTime() ?? 0)
+ progress: Math.round(getPlayerCurrentTime(player))
})
})
player?.on('volumechange', () => {
dispatchPlayer({
type: 'SetMute',
- mute: player?.muted() ?? false
+ mute: isPlayerReady(player) ? (player.muted() ?? false) : false
})
dispatchPlayer({
type: 'SetVolume',
- volume: (player?.volume() ?? 1) * 100
+ volume: getPlayerVolume(player)
})
})
player?.on('fullscreenchange', () => {
dispatchPlayer({
type: 'SetFullscreen',
- fullscreen: player?.isFullscreen() ?? false
+ fullscreen: isPlayerReady(player)
+ ? (player.isFullscreen() ?? false)
+ : false
})
})
player?.on('useractive', () =>
@@ -334,9 +357,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
Math.round(
- ((player?.currentTime() ?? 0) / (player?.duration() ?? 1)) * 100
+ (getPlayerCurrentTime(player) / getPlayerDuration(player)) * 100
)
)
})
@@ -361,9 +384,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
Math.round(
- ((player?.currentTime() ?? 0) / (player?.duration() ?? 1)) * 100
+ (getPlayerCurrentTime(player) / getPlayerDuration(player)) * 100
)
)
} else {
@@ -374,9 +397,9 @@ export function VideoControls({
title[0].value,
variant?.language?.name.find(({ primary }) => !primary)?.value ??
variant?.language?.name[0]?.value,
- Math.round(player?.currentTime() ?? 0),
+ Math.round(getPlayerCurrentTime(player)),
Math.round(
- ((player?.currentTime() ?? 0) / (player?.duration() ?? 1)) * 100
+ (getPlayerCurrentTime(player) / getPlayerDuration(player)) * 100
)
)
}
@@ -389,9 +412,9 @@ export function VideoControls({
function handlePlay(): void {
if (!play) {
- void player?.play()
+ if (isPlayerReady(player)) void player.play()
} else {
- void player?.pause()
+ if (isPlayerReady(player)) void player.pause()
}
}
@@ -404,7 +427,7 @@ export function VideoControls({
})
} else {
if (isMobile()) {
- void player?.requestFullscreen()
+ if (isPlayerReady(player)) void player.requestFullscreen()
dispatchPlayer({
type: 'SetFullscreen',
fullscreen: true
@@ -425,12 +448,12 @@ export function VideoControls({
type: 'SetProgress',
progress: value
})
- player?.currentTime(value)
+ if (isPlayerReady(player)) player.currentTime(value)
}
}
function handleMute(): void {
- player?.muted(!mute)
+ if (isPlayerReady(player)) player.muted(!mute)
dispatchPlayer({
type: 'SetMute',
mute: !mute
@@ -444,7 +467,7 @@ export function VideoControls({
type: 'SetVolume',
volume: value
})
- player?.volume(value / 100)
+ if (isPlayerReady(player)) player.volume(value / 100)
}
}
@@ -492,7 +515,7 @@ export function VideoControls({
onClick={getClickHandler(handlePlay, () => {
void handleFullscreen()
})}
- onMouseMove={() => player?.userActive(true)}
+ onMouseMove={() => isPlayerReady(player) && player.userActive(true)}
data-testid="VideoControls"
>
{!loading ? (
@@ -530,7 +553,7 @@ export function VideoControls({
{images[0]?.mobileCinematicHigh != null && (