diff --git a/doc/api/Player_Events.md b/doc/api/Player_Events.md index b5612790e1..b480c1b169 100644 --- a/doc/api/Player_Events.md +++ b/doc/api/Player_Events.md @@ -94,6 +94,34 @@ The object emitted as the following properties: That is the real live position (and not the position as announced by the video element). +### play + +Emitted when the `RxPlayer`'s `videoElement` is no longer considered paused. + +This event is generally triggered when and if the +[`play`](./Basic_Methods/play.md) method has succeeded. + +Note that this event can be sent even if the [player's state](./Player_States.md) +doesn't currently allow playback, for example when in the `"LOADING"` or +`"BUFFERING"` states, among other. +It shouldn't be sent however when the player's state is `"STOPPED"` which is +when no content is loading nor loaded. + +### pause + +Emitted when the `RxPlayer`'s `videoElement` is now considered paused. + +This event is triggered when and if the [`pause`](./Basic_Methods/play.md) method +has succeeded, when the content has ended or due to other rare occurences: for +example if we could not automatically play after a `"LOADING"` or `"RELOADING"` +state due to [the browser's autoplay policies](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide). + +Note that this event can be sent even if the [player's state](./Player_States.md) +doesn't currently allow playback, for example when in the `"LOADING"` or +`"BUFFERING"` states, among other. +It shouldn't be sent however when the player's state is `"STOPPED"` which is +when no content is loading nor loaded. + ### seeking Emitted when a "seek" operation (to "move"/"skip" to another position) begins diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index f47c3abd64..7594813da3 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -122,9 +122,9 @@ import MediaElementTrackChoiceManager from "./tracks_management/media_element_tr import TrackChoiceManager from "./tracks_management/track_choice_manager"; import { constructPlayerStateReference, + emitPlayPauseEvents, emitSeekEvents, isLoadedState, - // emitSeekEvents, PLAYER_STATES, } from "./utils"; @@ -956,6 +956,53 @@ class Player extends EventEmitter { } }; + /** + * `TaskCanceller` allowing to stop emitting `"play"` and `"pause"` + * events. + * `null` when such events are not emitted currently. + */ + let playPauseEventsCanceller : TaskCanceller | null = null; + + /** + * Callback emitting `"play"` and `"pause`" events once the content is + * loaded, starting from the state indicated in argument. + * @param {boolean} willAutoPlay - If `false`, we're currently paused. + */ + const triggerPlayPauseEventsWhenReady = (willAutoPlay: boolean) => { + if (playPauseEventsCanceller !== null) { + playPauseEventsCanceller.cancel(); // cancel previous logic + playPauseEventsCanceller = null; + } + playerStateRef.onUpdate((val, stopListeningToStateUpdates) => { + if (!isLoadedState(val)) { + return; // content not loaded yet: no event + } + stopListeningToStateUpdates(); + if (playPauseEventsCanceller !== null) { + playPauseEventsCanceller.cancel(); + } + playPauseEventsCanceller = new TaskCanceller(); + playPauseEventsCanceller.linkToSignal(currentContentCanceller.signal); + if (willAutoPlay !== !videoElement.paused) { + // paused status is not at the expected value on load: emit event + if (videoElement.paused) { + this.trigger("pause", null); + } else { + this.trigger("play", null); + } + } + emitPlayPauseEvents(videoElement, + () => this.trigger("play", null), + () => this.trigger("pause", null), + currentContentCanceller.signal); + }, { emitCurrentValue: false, clearSignal: currentContentCanceller.signal }); + }; + + triggerPlayPauseEventsWhenReady(autoPlay); + initializer.addEventListener("reloadingMediaSource", (payload) => { + triggerPlayPauseEventsWhenReady(payload.autoPlay); + }); + /** * `TaskCanceller` allowing to stop emitting `"seeking"` and `"seeked"` * events. @@ -3066,6 +3113,8 @@ interface IPublicAPIEvent { availableTextTracksChange : IAvailableTextTrack[]; availableVideoTracksChange : IAvailableVideoTrack[]; decipherabilityUpdate : IDecipherabilityUpdateContent[]; + play: null; + pause: null; seeking : null; seeked : null; streamEvent : IStreamEvent; diff --git a/src/core/api/utils.ts b/src/core/api/utils.ts index 643dbe17dd..5f16da0ce9 100644 --- a/src/core/api/utils.ts +++ b/src/core/api/utils.ts @@ -70,6 +70,32 @@ export function emitSeekEvents( }, { includeLastObservation: true, clearSignal: cancelSignal }); } +/** + * @param {HTMLMediaElement} mediaElement + * @param {function} onPlay - Callback called when a play operation has started + * on `mediaElement`. + * @param {function} onPause - Callback called when a pause operation has + * started on `mediaElement`. + * @param {Object} cancelSignal - When triggered, stop calling callbacks and + * remove all listeners this function has registered. + */ +export function emitPlayPauseEvents( + mediaElement : HTMLMediaElement | null, + onPlay: () => void, + onPause: () => void, + cancelSignal : CancellationSignal +) : void { + if (cancelSignal.isCancelled() || mediaElement === null) { + return ; + } + mediaElement.addEventListener("play", onPlay); + mediaElement.addEventListener("pause", onPause); + cancelSignal.register(() => { + mediaElement.removeEventListener("play", onPlay); + mediaElement.removeEventListener("pause", onPause); + }); +} + /** Player state dictionnary. */ export const enum PLAYER_STATES { STOPPED = "STOPPED", diff --git a/tests/integration/utils/launch_tests_for_content.js b/tests/integration/utils/launch_tests_for_content.js index 34f9a260df..c274a7cbf3 100644 --- a/tests/integration/utils/launch_tests_for_content.js +++ b/tests/integration/utils/launch_tests_for_content.js @@ -1375,6 +1375,22 @@ export default function launchTestsForContent(manifestInfos) { }); describe("play", () => { + let pauseEventsSent = 0; + let playEventsSent = 0; + beforeEach(() => { + player.addEventListener("pause", () => { + pauseEventsSent++; + }); + player.addEventListener("play", () => { + playEventsSent++; + }); + }); + + afterEach(() => { + pauseEventsSent = 0; + playEventsSent = 0; + }); + it("should begin to play if LOADED", async () => { player.loadVideo({ url: manifestInfos.url, @@ -1382,9 +1398,13 @@ export default function launchTestsForContent(manifestInfos) { }); await waitForLoadedStateAfterLoadVideo(player); expect(player.getPlayerState()).to.equal("LOADED"); + expect(pauseEventsSent).to.equal(0); + expect(playEventsSent).to.equal(0); player.play(); await sleep(10); expect(player.getPlayerState()).to.equal("PLAYING"); + expect(pauseEventsSent).to.equal(0); + expect(playEventsSent).to.equal(1); }); it("should resume if paused", async () => { @@ -1396,16 +1416,38 @@ export default function launchTestsForContent(manifestInfos) { await waitForLoadedStateAfterLoadVideo(player); await sleep(100); expect(player.getPlayerState()).to.equal("PLAYING"); + expect(pauseEventsSent).to.equal(0); + expect(playEventsSent).to.equal(0); player.pause(); await sleep(100); expect(player.getPlayerState()).to.equal("PAUSED"); + expect(pauseEventsSent).to.equal(1); + expect(playEventsSent).to.equal(0); player.play(); await sleep(100); expect(player.getPlayerState()).to.equal("PLAYING"); + expect(pauseEventsSent).to.equal(1); + expect(playEventsSent).to.equal(1); }); }); describe("pause", () => { + let pauseEventsSent = 0; + let playEventsSent = 0; + beforeEach(() => { + player.addEventListener("pause", () => { + pauseEventsSent++; + }); + player.addEventListener("play", () => { + playEventsSent++; + }); + }); + + afterEach(() => { + pauseEventsSent = 0; + playEventsSent = 0; + }); + it("should have no effect when LOADED", async () => { await tryTestMultipleTimes( async function runTest(cancelTest) { @@ -1432,6 +1474,8 @@ export default function launchTestsForContent(manifestInfos) { await sleep(10); expect(player.getPlayerState()).to.equal("LOADED"); + expect(pauseEventsSent).to.equal(0); + expect(playEventsSent).to.equal(0); }, 3, function cleanUp() { @@ -1448,9 +1492,12 @@ export default function launchTestsForContent(manifestInfos) { }); await waitForLoadedStateAfterLoadVideo(player); expect(player.getPlayerState()).to.equal("PLAYING"); + expect(pauseEventsSent).to.equal(0); player.pause(); await sleep(100); expect(player.getPlayerState()).to.equal("PAUSED"); + expect(pauseEventsSent).to.equal(1); + expect(playEventsSent).to.equal(0); }); it("should do nothing if already paused", async () => { @@ -1461,12 +1508,17 @@ export default function launchTestsForContent(manifestInfos) { }); await waitForLoadedStateAfterLoadVideo(player); expect(player.getPlayerState()).to.equal("PLAYING"); + expect(pauseEventsSent).to.equal(0); player.pause(); await sleep(100); expect(player.getPlayerState()).to.equal("PAUSED"); + expect(pauseEventsSent).to.equal(1); + expect(playEventsSent).to.equal(0); player.pause(); await sleep(100); expect(player.getPlayerState()).to.equal("PAUSED"); + expect(pauseEventsSent).to.equal(1); + expect(playEventsSent).to.equal(0); }); }); @@ -1483,6 +1535,113 @@ export default function launchTestsForContent(manifestInfos) { player.seekTo(minimumPosition + 50); expect(player.getPosition()).to.be.closeTo(minimumPosition + 50, 0.5); }); + + it("should conserve pause if previously paused", async () => { + let pauseEventsSent = 0; + let playEventsSent = 0; + player.addEventListener("pause", () => { + pauseEventsSent++; + }); + player.addEventListener("play", () => { + playEventsSent++; + }); + player.loadVideo({ + url: manifestInfos.url, + transport, + }); + await waitForLoadedStateAfterLoadVideo(player); + player.seekTo(minimumPosition + 50); + await waitForState(player, "PAUSED"); + expect(pauseEventsSent).to.equal(0); + expect(playEventsSent).to.equal(0); + }); + + it("should still play if previously playing", async () => { + let pauseEventsSent = 0; + let playEventsSent = 0; + let nbPausedStates = 0; + player.addEventListener("pause", () => { + pauseEventsSent++; + }); + player.addEventListener("play", () => { + playEventsSent++; + }); + player.addEventListener("playerStateChange", (state) => { + if (state === "PAUSED") { + nbPausedStates++; + } + }); + player.loadVideo({ + url: manifestInfos.url, + transport, + autoPlay: true, + }); + await waitForLoadedStateAfterLoadVideo(player); + player.seekTo(minimumPosition + 50); + await waitForState(player, "PLAYING"); + expect(pauseEventsSent).to.equal(0); + expect(playEventsSent).to.equal(0); + expect(nbPausedStates).to.equal(0); + }); + + it("should be able to pause while seeking", async () => { + let pauseEventsSent = 0; + let playEventsSent = 0; + let nbPausedStates = 0; + player.addEventListener("pause", () => { + pauseEventsSent++; + }); + player.addEventListener("play", () => { + playEventsSent++; + }); + player.addEventListener("playerStateChange", (state) => { + if (state === "PAUSED") { + nbPausedStates++; + } + }); + player.loadVideo({ + url: manifestInfos.url, + transport, + autoPlay: true, + }); + await waitForLoadedStateAfterLoadVideo(player); + player.seekTo(minimumPosition + 50); + await sleep(0); + player.pause(); + await waitForState(player, "PAUSED"); + expect(pauseEventsSent).to.equal(1); + expect(playEventsSent).to.equal(0); + expect(nbPausedStates).to.equal(1); + }); + + it("should be able to play while seeking", async () => { + let pauseEventsSent = 0; + let playEventsSent = 0; + let nbPlayingStates = 0; + player.addEventListener("pause", () => { + pauseEventsSent++; + }); + player.addEventListener("play", () => { + playEventsSent++; + }); + player.addEventListener("playerStateChange", (state) => { + if (state === "PLAYING") { + nbPlayingStates++; + } + }); + player.loadVideo({ + url: manifestInfos.url, + transport, + }); + await waitForLoadedStateAfterLoadVideo(player); + player.seekTo(minimumPosition + 50); + await sleep(0); + player.play(); + await waitForState(player, "PLAYING"); + expect(pauseEventsSent).to.equal(0); + expect(playEventsSent).to.equal(1); + expect(nbPlayingStates).to.equal(1); + }); }); describe("getVolume", () => {