From 0f47e11d63c521aa10f3d7b0d7ae91ac031ff8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1l=20=C3=87al=C4=B1=C5=9Fkol?= Date: Sun, 30 Aug 2026 16:31:40 +0300 Subject: [PATCH] fix: silence unhandled play() rejections from internal callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `art.play()` is an async wrapper around the native `video.play()` promise, which rejects when a pending play request is interrupted (`pause()`, `load()`, a new `src`, or the element being removed) and when autoplay is blocked. Every internal caller fires it from an event handler and cannot await it, so the rejection escapes as a global `unhandledrejection`. Error trackers report it as an uncaught crash even though the player recovers on its own — for example destroying the player right after mount, which is routine when the video sits in a modal the user can close quickly. Add a `silencePromise` util (the same approach video.js uses) and apply it to the non-awaited internal call sites: mask, play control, mini player, loop restart, autoPlayback jump, hotkey and click toggles. `switchMix` is fixed too, for a second symptom: `await art.play()` inside the async `video:canplay` handler both leaked the rejection and left the `switchUrl` promise pending forever, since the throw never reached the enclosing executor's `reject`. It now settles. The public API is unchanged: `art.play()` and `art.toggle()` still return the original promise, so applications that call them keep their own rejection to handle. Only the internal, non-awaited copy is silenced. --- .../artplayer/src/control/playAndPause.js | 4 +-- packages/artplayer/src/events/clickInit.js | 8 +++--- packages/artplayer/src/hotkey.js | 4 +-- packages/artplayer/src/mask.js | 4 +-- packages/artplayer/src/player/eventInit.js | 4 +-- packages/artplayer/src/player/miniMix.js | 4 +-- packages/artplayer/src/player/switchMix.js | 4 +-- .../artplayer/src/plugins/autoPlayback.js | 4 +-- packages/artplayer/src/utils/error.js | 25 +++++++++++++++++++ packages/artplayer/types/utils.d.ts | 1 + 10 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/artplayer/src/control/playAndPause.js b/packages/artplayer/src/control/playAndPause.js index a23f1b019..957e75e91 100644 --- a/packages/artplayer/src/control/playAndPause.js +++ b/packages/artplayer/src/control/playAndPause.js @@ -1,4 +1,4 @@ -import { append, setStyle, tooltip } from '../utils' +import { append, setStyle, silencePromise, tooltip } from '../utils' export default function playAndPause(option) { return art => ({ @@ -12,7 +12,7 @@ export default function playAndPause(option) { tooltip($pause, i18n.get('Pause')) proxy($play, 'click', () => { - art.play() + silencePromise(art.play()) }) proxy($pause, 'click', () => { diff --git a/packages/artplayer/src/events/clickInit.js b/packages/artplayer/src/events/clickInit.js index ec5618bb0..3e86a09c4 100644 --- a/packages/artplayer/src/events/clickInit.js +++ b/packages/artplayer/src/events/clickInit.js @@ -1,4 +1,4 @@ -import { includeFromEvent, isMobile } from '../utils' +import { includeFromEvent, isMobile, silencePromise } from '../utils' export default function clickInit(art, events) { const { @@ -35,11 +35,11 @@ export default function clickInit(art, events) { if (isMobile) { if (!art.isLock && MOBILE_CLICK_PLAY) { - art.toggle() + silencePromise(art.toggle()) } } else { - art.toggle() + silencePromise(art.toggle()) } clickTimes = clicks break @@ -48,7 +48,7 @@ export default function clickInit(art, events) { if (isMobile) { if (!art.isLock && MOBILE_DBCLICK_PLAY) { - art.toggle() + silencePromise(art.toggle()) } } else { diff --git a/packages/artplayer/src/hotkey.js b/packages/artplayer/src/hotkey.js index 28d922036..2a5cd6367 100644 --- a/packages/artplayer/src/hotkey.js +++ b/packages/artplayer/src/hotkey.js @@ -1,4 +1,4 @@ -import { isMobile } from './utils' +import { isMobile, silencePromise } from './utils' export default class Hotkey { constructor(art) { @@ -21,7 +21,7 @@ export default class Hotkey { }) this.add('Space', () => { - this.art.toggle() + silencePromise(this.art.toggle()) }) this.add('ArrowLeft', () => { diff --git a/packages/artplayer/src/mask.js b/packages/artplayer/src/mask.js index c92875111..7449454ad 100644 --- a/packages/artplayer/src/mask.js +++ b/packages/artplayer/src/mask.js @@ -1,4 +1,4 @@ -import { append, setStyle } from './utils' +import { append, setStyle, silencePromise } from './utils' import Component from './utils/component' export default class Mask extends Component { @@ -18,6 +18,6 @@ export default class Mask extends Component { setStyle($error, 'display', null) }) - events.proxy(template.$state, 'click', () => art.play()) + events.proxy(template.$state, 'click', () => silencePromise(art.play())) } } diff --git a/packages/artplayer/src/player/eventInit.js b/packages/artplayer/src/player/eventInit.js index c5effc436..f265c7635 100644 --- a/packages/artplayer/src/player/eventInit.js +++ b/packages/artplayer/src/player/eventInit.js @@ -1,5 +1,5 @@ import config from '../config' -import { addClass, isMobile, setStyle, sleep } from '../utils' +import { addClass, isMobile, setStyle, silencePromise, sleep } from '../utils' export default function eventInit(art) { const { @@ -51,7 +51,7 @@ export default function eventInit(art) { art.on('video:ended', () => { if (option.loop) { art.seek = 0 - art.play() + silencePromise(art.play()) art.controls.show = false art.mask.show = false } diff --git a/packages/artplayer/src/player/miniMix.js b/packages/artplayer/src/player/miniMix.js index f54c37196..158531bc0 100644 --- a/packages/artplayer/src/player/miniMix.js +++ b/packages/artplayer/src/player/miniMix.js @@ -1,4 +1,4 @@ -import { addClass, append, createElement, def, getRect, hasClass, isInViewport, removeClass, setStyle } from '../utils' +import { addClass, append, createElement, def, getRect, hasClass, isInViewport, removeClass, setStyle, silencePromise } from '../utils' export default function miniMix(art) { const { @@ -53,7 +53,7 @@ export default function miniMix(art) { const $state = append($mini, `
`) const $play = append($state, icons.play) const $pause = append($state, icons.pause) - proxy($play, 'click', () => art.play()) + proxy($play, 'click', () => silencePromise(art.play())) proxy($pause, 'click', () => art.pause()) initState($play, $pause) art.on('video:playing', () => initState($play, $pause)) diff --git a/packages/artplayer/src/player/switchMix.js b/packages/artplayer/src/player/switchMix.js index 186005e7b..f8913f981 100644 --- a/packages/artplayer/src/player/switchMix.js +++ b/packages/artplayer/src/player/switchMix.js @@ -1,4 +1,4 @@ -import { def } from '../utils' +import { def, silencePromise } from '../utils' export default function switchMix(art) { function switchUrl(url, currentTime) { @@ -31,7 +31,7 @@ export default function switchMix(art) { art.aspectRatio = aspectRatio if (playing) { - await art.play() + await silencePromise(art.play()) } art.notice.show = '' diff --git a/packages/artplayer/src/plugins/autoPlayback.js b/packages/artplayer/src/plugins/autoPlayback.js index 9f065249c..e36aaf4f0 100644 --- a/packages/artplayer/src/plugins/autoPlayback.js +++ b/packages/artplayer/src/plugins/autoPlayback.js @@ -1,4 +1,4 @@ -import { append, query, secondToTime, setStyle } from '../utils' +import { append, query, secondToTime, setStyle, silencePromise } from '../utils' export default function autoPlayback(art) { const { @@ -57,7 +57,7 @@ export default function autoPlayback(art) { proxy($jump, 'click', () => { art.seek = currentTime - art.play() + silencePromise(art.play()) setStyle($poster, 'display', 'none') setStyle($autoPlayback, 'display', 'none') }) diff --git a/packages/artplayer/src/utils/error.js b/packages/artplayer/src/utils/error.js index 5b0e87495..f0ebe46da 100644 --- a/packages/artplayer/src/utils/error.js +++ b/packages/artplayer/src/utils/error.js @@ -14,3 +14,28 @@ export function errorHandle(condition, msg) { } return condition } + +/** + * Attach a no-op catch to a promise that is intentionally not awaited. + * + * `art.play()` resolves the native `video.play()` promise, which rejects when the + * request is interrupted (a `pause()`, a `load()`, a new `src`, or the element + * being removed while the request is still pending) or when autoplay is blocked. + * Internal call sites fire it from event handlers and cannot await it, so the + * rejection escapes as a global `unhandledrejection` and error trackers report it + * as a crash even though the player recovers on its own. + * + * Nothing is hidden from users of the public API: `art.play()` and `art.toggle()` + * still return the original promise, so an application that calls them keeps its + * own rejection to handle. Only the internal, non-awaited copy is silenced. + * + * Returns a promise that always fulfills, so an internal caller may await it + * without inheriting the rejection. A non-thenable value is returned untouched + * (`art.toggle()` yields `art.pause()`, which is synchronous). + */ +export function silencePromise(value) { + if (value && typeof value.catch === 'function') { + return value.catch(() => {}) + } + return value +} diff --git a/packages/artplayer/types/utils.d.ts b/packages/artplayer/types/utils.d.ts index 2f32b794b..af4f345de 100644 --- a/packages/artplayer/types/utils.d.ts +++ b/packages/artplayer/types/utils.d.ts @@ -43,6 +43,7 @@ export interface Utils { loadImg: (url: string, scale?: number) => Promise errorHandle: (condition: T, msg: string) => T extends true ? T : never + silencePromise: (value: T) => T extends PromiseLike ? Promise : T def: (obj: object, name: string, value: unknown) => void has: (obj: object, name: PropertyKey) => boolean get: (obj: object, name: PropertyKey) => PropertyDescriptor | undefined