From 44675210fa3048d359968f326d9438a9521267f1 Mon Sep 17 00:00:00 2001 From: mister-ben Date: Fri, 28 Apr 2023 17:08:24 +0200 Subject: [PATCH 01/15] feat: Add mediasession support --- package-lock.json | 47 ++++----- package.json | 2 +- src/js/player.js | 101 ++++++++++++++++++ test/unit/player-mediasession.test.js | 144 ++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 28 deletions(-) create mode 100644 test/unit/player-mediasession.test.js diff --git a/package-lock.json b/package-lock.json index 78ec2882b3..2825676574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6476,9 +6476,9 @@ "dev": true }, "globalyzer": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.4.tgz", - "integrity": "sha512-LeguVWaxgHN0MNbWC6YljNMzHkrCny9fzjmEUdnF1kQ7wATFD1RHFRqA1qxaX2tgxGENlcxjOflopBwj3YZiXA==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", + "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", "dev": true }, "globby": { @@ -7934,12 +7934,6 @@ } } }, - "js-reporters": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/js-reporters/-/js-reporters-1.2.3.tgz", - "integrity": "sha512-2YzWkHbbRu6LueEs5ZP3P1LqbECvAeUJYrjw3H4y1ofW06hqCS0AbzBtLwbr+Hke51bt9CUepJ/Fj1hlCRIF6A==", - "dev": true - }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -10152,9 +10146,9 @@ "dev": true }, "node-watch": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.7.0.tgz", - "integrity": "sha512-OOBiglke5SlRQT5WYfwXTmYqTfXjcTNBHpalyHLtLxDpQYVpVRkJqabcch1kmwJsjV/J4OZuzEafeb4soqtFZA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.7.3.tgz", + "integrity": "sha512-3l4E8uMPY1HdMMryPRUAl+oIHtXtyiTlIiESNSVSNxcPfzAFzeTbXFQkZfAwBbo0B1qMSG8nUABx+Gd+YrbKrQ==", "dev": true }, "nomnom": { @@ -11341,21 +11335,20 @@ "dev": true }, "qunit": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/qunit/-/qunit-2.13.0.tgz", - "integrity": "sha512-RvJquyNKbMSn5Qo28S2wKWxHl1Ku8m0zFLTKsXfq/WZkyM+b28gpEs6YkKN1fOCV4S+979+GnevD0FRgQayo3Q==", + "version": "2.19.4", + "resolved": "https://registry.npmjs.org/qunit/-/qunit-2.19.4.tgz", + "integrity": "sha512-aqUzzUeCqlleWYKlpgfdHHw9C6KxkB9H3wNfiBg5yHqQMzy0xw/pbCRHYFkjl8MsP/t8qkTQE+JTYL71azgiew==", "dev": true, "requires": { - "commander": "6.2.0", - "js-reporters": "1.2.3", - "node-watch": "0.7.0", - "tiny-glob": "0.2.6" + "commander": "7.2.0", + "node-watch": "0.7.3", + "tiny-glob": "0.2.9" }, "dependencies": { "commander": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", - "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "dev": true } } @@ -13885,13 +13878,13 @@ } }, "tiny-glob": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.6.tgz", - "integrity": "sha512-A7ewMqPu1B5PWwC3m7KVgAu96Ch5LA0w4SnEN/LbDREj/gAD0nPWboRbn8YoP9ISZXqeNAlMvKSKoEuhcfK3Pw==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", + "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", "dev": true, "requires": { - "globalyzer": "^0.1.0", - "globrex": "^0.1.1" + "globalyzer": "0.1.0", + "globrex": "^0.1.2" } }, "tmp": { diff --git a/package.json b/package.json index 6581c4026e..41c145e3e5 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "npm-run-all": "^4.1.5", "postcss": "^8.2.13", "postcss-cli": "^8.3.1", - "qunit": "2.13.0", + "qunit": "^2.19.4", "remark-cli": "^6.0.1", "remark-lint": "^6.0.6", "remark-parse": "^6.0.3", diff --git a/src/js/player.js b/src/js/player.js index 7d42ddd224..a03d454375 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -580,6 +580,107 @@ class Player extends Component { this.audioPosterMode(this.options_.audioPosterMode); this.audioOnlyMode(this.options_.audioOnlyMode); }); + + if (options.mediaSession && 'mediaSession' in window.navigator) { + const ms = window.navigator.mediaSession; + const skipTime = options.mediaSession.skipTime || 15; + + const actionHandlers = [ + ['play', () => { + this.play(); + }], + ['pause', () => { + this.pause(); + }], + ['stop', () => { + this.pause(); + this.currentTime(0); + }], + ['seekbackward', (details) => { + if (this.usingPlugin('ads') && this.ads.inAdBreak()) { + return; + } + this.currentTime(Math.max(0, this.currentTime() - (details.skipOffset || skipTime))); + }], + ['seekforward', (details) => { + if (this.usingPlugin('ads') && this.ads.inAdBreak()) { + return; + } + this.currentTime(Math.min(this.duration(), this.currentTime() + (details.skipOffset || skipTime))); + }], + ['seekto', (details) => { + if (this.usingPlugin('ads') && this.ads.inAdBreak()) { + return; + } + this.currentTime(details.seekTime); + }] + ]; + + for (const [action, handler] of actionHandlers) { + try { + ms.setActionHandler(action, handler); + } catch (error) { + this.log.debug(`Couldn't register media session action "${action}".`); + } + } + + const setUpMediaSessionPlaylist = () => { + try { + ms.setActionHandler('previoustrack', () => { + this.playlist.previous(); + }); + ms.setActionHandler('nexttrack', () => { + this.playlist.next(); + }); + } catch (error) { + this.log('Couldn\'t register playlist media session actions.'); + } + }; + + // Only setup playlist handlers if / when playlist plugin is present + if (this.usingPlugin('playlist')) { + setUpMediaSessionPlaylist(); + } else { + this.on('pluginsetup:playlist', setUpMediaSessionPlaylist); + } + + /** + * @fires Player#updatemediasession + */ + const updateMediaSession = () => { + this.log('updatems'); + const currentMedia = this.getMedia(); + const playlistItem = this.usingPlugin('playlist') ? Object.assign({}, this.playlist()[this.playlist.currentItem()]) : {}; + const mediaSessionData = { + artwork: currentMedia.artwork || playlistItem.artwork || this.poster() ? [{ + src: this.poster(), + type: getMimetype(this.poster()) + }] : [], + title: currentMedia.title || playlistItem.name || '', + artist: currentMedia.artist || playlistItem.artist || '', + album: currentMedia.album || playlistItem.album || '' + }; + + // This allows the metadata to be updated before being set, e.g. if loadmedia() is not used. + this.trigger('updatemediasession', mediaSessionData); + + ms.metadata = new window.MediaMetadata(mediaSessionData); + }; + + const updatePositionState = () => { + ms.setPositionState({ + duration: this.duration(), + playbackRate: this.playbackRate(), + position: this.currentTime() + }); + }; + + this.on('playing', updateMediaSession); + + if ('setPositionState' in ms) { + this.on(['playing', 'seeked', 'ratechange'], updatePositionState); + } + } } /** diff --git a/test/unit/player-mediasession.test.js b/test/unit/player-mediasession.test.js new file mode 100644 index 0000000000..8bfda3bf41 --- /dev/null +++ b/test/unit/player-mediasession.test.js @@ -0,0 +1,144 @@ +/* eslint-env qunit */ +import TestHelpers from './test-helpers'; +import sinon from 'sinon'; +import window from 'global/window'; + +QUnit.module('Player: MediaSession', { + afterEach() { + this.player.dispose(); + } +}); + +const testOrSkip = 'mediasession' in window.navigator ? 'test' : 'skip'; + +QUnit[testOrSkip]('mediasession data is populated from getMedia', function(assert) { + const done = assert.async(); + + this.player = TestHelpers.makePlayer({ + mediaSession: true + }); + + this.player.loadMedia({ + artist: 'Artist', + album: 'Album', + title: 'Title', + description: 'Description', + poster: 'poster.jpg', + src: 'foo.mp4' + }); + + this.player.on('updatemediasession', (e, mediaSessionData) => { + assert.deepEqual(mediaSessionData, { + artist: 'Artist', + album: 'Album', + title: 'Title', + artwork: [ + { + src: 'poster.jpg', + type: 'image/jpeg' + } + ] + }, 'mediasession data as expected from getMedia'); + done(); + }); + + this.player.trigger('playing'); +}); + +QUnit[testOrSkip]('mediasession data is populated from playlist', function(assert) { + const done = assert.async(); + + this.player = TestHelpers.makePlayer({ + mediaSession: true + }); + + this.player.usingPlugin = (plugin) => { + if (plugin === 'playlist') { + return true; + } + }; + this.player.playlist = () => [{ + artist: 'ArtistPlaylist', + album: 'AlbumPlaylist', + name: 'TitlePlaylist' + }]; + this.player.playlist.currentItem = () => 0; + this.player.poster('posterPlaylist.jpg'); + + this.player.on('updatemediasession', (e, mediaSessionData) => { + assert.deepEqual(mediaSessionData, { + artist: 'ArtistPlaylist', + album: 'AlbumPlaylist', + title: 'TitlePlaylist', + artwork: [ + { + src: 'posterPlaylist.jpg', + type: 'image/jpeg' + } + ] + }, 'mediasession data as expected from playlist'); + done(); + }); + + this.player.trigger('playing'); +}); + +QUnit[testOrSkip]('mediasession data set', function(assert) { + this.clock = sinon.useFakeTimers(); + this.player = TestHelpers.makePlayer({ + mediaSession: true + }); + + this.player.loadMedia({ + artist: 'ArtistA', + album: 'AlbumA', + title: 'TitleA', + description: 'DescriptionA', + poster: 'posterA.jpg', + src: 'fooA.mp4' + }); + + this.player.trigger('playing'); + + this.clock.tick(100); + + assert.equal(window.navigator.mediaSession.metadata.artist, 'ArtistA', 'mediasession artist retrieved'); + assert.equal(window.navigator.mediaSession.metadata.title, 'TitleA', 'mediasession title retrieved'); + assert.equal(window.navigator.mediaSession.metadata.album, 'AlbumA', 'mediasession album retrieved'); + assert.ok(window.navigator.mediaSession.metadata.artwork[0].src.endsWith('posterA.jpg'), 'mediasession poster retrieved'); + + this.clock.restore(); +}); + +QUnit[testOrSkip]('mediasession can be customised befire being set', function(assert) { + assert.expect(3); + + this.clock = sinon.useFakeTimers(); + this.player = TestHelpers.makePlayer({ + mediaSession: true + }); + + this.player.loadMedia({ + artist: 'ArtistB', + album: 'AlbumB', + title: 'TitleB', + description: 'DescriptionB', + poster: 'posterB.jpg', + src: 'fooB.mp4' + }); + + this.player.on('updatemediasession', function(e, metadata) { + assert.ok(true, 'updatemediasession triggered'); + metadata.artist = 'Another artist'; + }); + + this.player.trigger('playing'); + + this.clock.tick(100); + + assert.equal(window.navigator.mediaSession.metadata.artist, 'Another artist', 'set with updated artist'); + assert.equal(window.navigator.mediaSession.metadata.title, 'TitleB', 'mediasession original title used'); + + this.clock.restore(); +}); + From 9be74c3e74d035abfb79fffc7f3f7180d8429a70 Mon Sep 17 00:00:00 2001 From: mister-ben Date: Fri, 2 Jun 2023 10:10:17 +0200 Subject: [PATCH 02/15] move to separate file --- src/js/mediasession.js | 112 +++++++++++++++++++++++++++++++++++++++++ src/js/player.js | 101 +------------------------------------ 2 files changed, 114 insertions(+), 99 deletions(-) create mode 100644 src/js/mediasession.js diff --git a/src/js/mediasession.js b/src/js/mediasession.js new file mode 100644 index 0000000000..f0cf7781fb --- /dev/null +++ b/src/js/mediasession.js @@ -0,0 +1,112 @@ +import window from 'global/window'; +import {getMimetype} from './utils/mimetypes'; + +/** + * + * Sets up media session if supported and configured + * + * @this Player + */ +export const initMediaSession = function() { + if (!this.options_.mediaSession || !('mediaSession' in window.navigator)) { + return; + } + const ms = window.navigator.mediaSession; + const skipTime = this.options_.mediaSession.skipTime || 15; + + const actionHandlers = [ + ['play', () => { + this.play(); + }], + ['pause', () => { + this.pause(); + }], + ['stop', () => { + this.pause(); + this.currentTime(0); + }], + ['seekbackward', (details) => { + if (this.usingPlugin('ads') && this.ads.inAdBreak()) { + return; + } + this.currentTime(Math.max(0, this.currentTime() - (details.skipOffset || skipTime))); + }], + ['seekforward', (details) => { + if (this.usingPlugin('ads') && this.ads.inAdBreak()) { + return; + } + this.currentTime(Math.min(this.duration(), this.currentTime() + (details.skipOffset || skipTime))); + }], + ['seekto', (details) => { + if (this.usingPlugin('ads') && this.ads.inAdBreak()) { + return; + } + this.currentTime(details.seekTime); + }] + ]; + + for (const [action, handler] of actionHandlers) { + try { + ms.setActionHandler(action, handler); + } catch (error) { + this.log.debug(`Couldn't register media session action "${action}".`); + } + } + + const setUpMediaSessionPlaylist = () => { + try { + ms.setActionHandler('previoustrack', () => { + this.playlist.previous(); + }); + ms.setActionHandler('nexttrack', () => { + this.playlist.next(); + }); + } catch (error) { + this.log('Couldn\'t register playlist media session actions.'); + } + }; + + // Only setup playlist handlers if / when playlist plugin is present + if (this.usingPlugin('playlist')) { + setUpMediaSessionPlaylist(); + } else { + this.on('pluginsetup:playlist', setUpMediaSessionPlaylist); + } + + /** + * @fires Player#updatemediasession + */ + const updateMediaSession = () => { + this.log('updatems'); + const currentMedia = this.getMedia(); + const playlistItem = this.usingPlugin('playlist') ? Object.assign({}, this.playlist()[this.playlist.currentItem()]) : {}; + const mediaSessionData = { + artwork: currentMedia.artwork || playlistItem.artwork || this.poster() ? [{ + src: this.poster(), + type: getMimetype(this.poster()) + }] : [], + title: currentMedia.title || playlistItem.name || '', + artist: currentMedia.artist || playlistItem.artist || '', + album: currentMedia.album || playlistItem.album || '' + }; + + // This allows the metadata to be updated before being set, e.g. if loadmedia() is not used. + this.trigger('updatemediasession', mediaSessionData); + + ms.metadata = new window.MediaMetadata(mediaSessionData); + }; + + const updatePositionState = () => { + ms.setPositionState({ + duration: this.duration(), + playbackRate: this.playbackRate(), + position: this.currentTime() + }); + }; + + this.on('playing', updateMediaSession); + + if ('setPositionState' in ms) { + this.on(['playing', 'seeked', 'ratechange'], updatePositionState); + } +}; diff --git a/src/js/player.js b/src/js/player.js index 9f2eb30bde..e8013f0bb9 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -35,6 +35,7 @@ import {getMimetype, findMimetype} from './utils/mimetypes'; import {hooks} from './utils/hooks'; import {isObject} from './utils/obj'; import keycode from 'keycode'; +import { initMediaSession } from './mediasession.js'; // The following imports are used only to ensure that the corresponding modules // are always included in the video.js package. Importing the modules will @@ -583,106 +584,8 @@ class Player extends Component { this.audioOnlyMode(this.options_.audioOnlyMode); }); - if (options.mediaSession && 'mediaSession' in window.navigator) { - const ms = window.navigator.mediaSession; - const skipTime = options.mediaSession.skipTime || 15; - - const actionHandlers = [ - ['play', () => { - this.play(); - }], - ['pause', () => { - this.pause(); - }], - ['stop', () => { - this.pause(); - this.currentTime(0); - }], - ['seekbackward', (details) => { - if (this.usingPlugin('ads') && this.ads.inAdBreak()) { - return; - } - this.currentTime(Math.max(0, this.currentTime() - (details.skipOffset || skipTime))); - }], - ['seekforward', (details) => { - if (this.usingPlugin('ads') && this.ads.inAdBreak()) { - return; - } - this.currentTime(Math.min(this.duration(), this.currentTime() + (details.skipOffset || skipTime))); - }], - ['seekto', (details) => { - if (this.usingPlugin('ads') && this.ads.inAdBreak()) { - return; - } - this.currentTime(details.seekTime); - }] - ]; - - for (const [action, handler] of actionHandlers) { - try { - ms.setActionHandler(action, handler); - } catch (error) { - this.log.debug(`Couldn't register media session action "${action}".`); - } - } - - const setUpMediaSessionPlaylist = () => { - try { - ms.setActionHandler('previoustrack', () => { - this.playlist.previous(); - }); - ms.setActionHandler('nexttrack', () => { - this.playlist.next(); - }); - } catch (error) { - this.log('Couldn\'t register playlist media session actions.'); - } - }; - - // Only setup playlist handlers if / when playlist plugin is present - if (this.usingPlugin('playlist')) { - setUpMediaSessionPlaylist(); - } else { - this.on('pluginsetup:playlist', setUpMediaSessionPlaylist); - } + initMediaSession.bind(this)(); - /** - * @fires Player#updatemediasession - */ - const updateMediaSession = () => { - this.log('updatems'); - const currentMedia = this.getMedia(); - const playlistItem = this.usingPlugin('playlist') ? Object.assign({}, this.playlist()[this.playlist.currentItem()]) : {}; - const mediaSessionData = { - artwork: currentMedia.artwork || playlistItem.artwork || this.poster() ? [{ - src: this.poster(), - type: getMimetype(this.poster()) - }] : [], - title: currentMedia.title || playlistItem.name || '', - artist: currentMedia.artist || playlistItem.artist || '', - album: currentMedia.album || playlistItem.album || '' - }; - - // This allows the metadata to be updated before being set, e.g. if loadmedia() is not used. - this.trigger('updatemediasession', mediaSessionData); - - ms.metadata = new window.MediaMetadata(mediaSessionData); - }; - - const updatePositionState = () => { - ms.setPositionState({ - duration: this.duration(), - playbackRate: this.playbackRate(), - position: this.currentTime() - }); - }; - - this.on('playing', updateMediaSession); - - if ('setPositionState' in ms) { - this.on(['playing', 'seeked', 'ratechange'], updatePositionState); - } - } } /** From aa076f47316ecfbd5b3977e22033c1c8636c1701 Mon Sep 17 00:00:00 2001 From: mister-ben Date: Fri, 2 Jun 2023 17:28:52 +0200 Subject: [PATCH 03/15] add type --- src/js/mediasession.js | 2 +- src/js/player.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/js/mediasession.js b/src/js/mediasession.js index f0cf7781fb..8a1559a961 100644 --- a/src/js/mediasession.js +++ b/src/js/mediasession.js @@ -5,7 +5,7 @@ import {getMimetype} from './utils/mimetypes'; * * Sets up media session if supported and configured * - * @this Player + * @this { import('./player').default } */ export const initMediaSession = function() { if (!this.options_.mediaSession || !('mediaSession' in window.navigator)) { diff --git a/src/js/player.js b/src/js/player.js index d58755165f..de28113b7a 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -584,7 +584,8 @@ class Player extends Component { this.audioOnlyMode(this.options_.audioOnlyMode); }); - initMediaSession.bind(this)(); + // Set up media session if supported + initMediaSession.call(this); } From c9bd62520e2388bbb308350e64d160d88eff4548 Mon Sep 17 00:00:00 2001 From: mister-ben Date: Fri, 2 Jun 2023 17:55:52 +0200 Subject: [PATCH 04/15] mock test --- test/unit/player-mediasession.test.js | 39 +++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/test/unit/player-mediasession.test.js b/test/unit/player-mediasession.test.js index 8bfda3bf41..4b26a088e5 100644 --- a/test/unit/player-mediasession.test.js +++ b/test/unit/player-mediasession.test.js @@ -4,14 +4,43 @@ import sinon from 'sinon'; import window from 'global/window'; QUnit.module('Player: MediaSession', { + before() { + if (!('mediaSession' in window.navigator)) { + window.navigator.mediaSession = { + setPositionState: () => {}, + setHandlerAction: () => {}, + metadata: {}, + _mocked: true + }; + + // Object.defineProperty(window.navigator, 'mediaSession', { + // configurable: true, + // enumerable: true, + // value: mockMediaSession, + // writable: true + // }); + + window.navigator.MediaMetadata = class MediaMetadata { + constructor(data) { + return data; + } + }; + } + }, afterEach() { this.player.dispose(); + }, + after() { + if (window.navigator.mediaSession._mocked) { + delete window.navigator.mediaSession; + delete window.navigator.MediaMetadata; + } } }); -const testOrSkip = 'mediasession' in window.navigator ? 'test' : 'skip'; +// const testOrSkip = 'mediasession' in window.navigator ? 'test' : 'skip'; -QUnit[testOrSkip]('mediasession data is populated from getMedia', function(assert) { +QUnit.test('mediasession data is populated from getMedia', function(assert) { const done = assert.async(); this.player = TestHelpers.makePlayer({ @@ -45,7 +74,7 @@ QUnit[testOrSkip]('mediasession data is populated from getMedia', function(asser this.player.trigger('playing'); }); -QUnit[testOrSkip]('mediasession data is populated from playlist', function(assert) { +QUnit.test('mediasession data is populated from playlist', function(assert) { const done = assert.async(); this.player = TestHelpers.makePlayer({ @@ -83,7 +112,7 @@ QUnit[testOrSkip]('mediasession data is populated from playlist', function(asser this.player.trigger('playing'); }); -QUnit[testOrSkip]('mediasession data set', function(assert) { +QUnit.test('mediasession data set', function(assert) { this.clock = sinon.useFakeTimers(); this.player = TestHelpers.makePlayer({ mediaSession: true @@ -110,7 +139,7 @@ QUnit[testOrSkip]('mediasession data set', function(assert) { this.clock.restore(); }); -QUnit[testOrSkip]('mediasession can be customised befire being set', function(assert) { +QUnit.test('mediasession can be customised befire being set', function(assert) { assert.expect(3); this.clock = sinon.useFakeTimers(); From b9c3aa6e5d66b64e1926af1fdbe283fd4fe1f6fb Mon Sep 17 00:00:00 2001 From: mister-ben Date: Fri, 2 Jun 2023 18:07:04 +0200 Subject: [PATCH 05/15] guard against non finite duration --- src/js/mediasession.js | 14 ++++--- test/unit/player-mediasession.test.js | 59 ++++++++++++++------------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/js/mediasession.js b/src/js/mediasession.js index 8a1559a961..c8d8e52b6a 100644 --- a/src/js/mediasession.js +++ b/src/js/mediasession.js @@ -97,11 +97,15 @@ export const initMediaSession = function() { }; const updatePositionState = () => { - ms.setPositionState({ - duration: this.duration(), - playbackRate: this.playbackRate(), - position: this.currentTime() - }); + const dur = parseFloat(this.duration()); + + if (Number.isFinite(dur) && parseFloat(dur)) { + ms.setPositionState({ + duration: dur, + playbackRate: this.playbackRate(), + position: this.currentTime() + }); + } }; this.on('playing', updateMediaSession); diff --git a/test/unit/player-mediasession.test.js b/test/unit/player-mediasession.test.js index 4b26a088e5..1f95cd0b0f 100644 --- a/test/unit/player-mediasession.test.js +++ b/test/unit/player-mediasession.test.js @@ -4,38 +4,39 @@ import sinon from 'sinon'; import window from 'global/window'; QUnit.module('Player: MediaSession', { - before() { - if (!('mediaSession' in window.navigator)) { - window.navigator.mediaSession = { - setPositionState: () => {}, - setHandlerAction: () => {}, - metadata: {}, - _mocked: true - }; - - // Object.defineProperty(window.navigator, 'mediaSession', { - // configurable: true, - // enumerable: true, - // value: mockMediaSession, - // writable: true - // }); - - window.navigator.MediaMetadata = class MediaMetadata { - constructor(data) { - return data; - } - }; - } - }, + // before() { + // if (!('mediaSession' in window.navigator)) { + // window.navigator.mediaSession = { + // setPositionState: () => {}, + // setHandlerAction: () => {}, + // metadata: {}, + // _mocked: true + // }; + + // // Object.defineProperty(window.navigator, 'mediaSession', { + // // configurable: true, + // // enumerable: true, + // // value: mockMediaSession, + // // writable: true + // // }); + + // window.MediaMetadata = class MediaMetadata { + // constructor(data) { + // return data; + // } + // }; + // } + // }, afterEach() { this.player.dispose(); - }, - after() { - if (window.navigator.mediaSession._mocked) { - delete window.navigator.mediaSession; - delete window.navigator.MediaMetadata; - } } + // , + // after() { + // if (window.navigator.mediaSession._mocked) { + // delete window.navigator.mediaSession; + // delete window.MediaMetadata; + // } + // } }); // const testOrSkip = 'mediasession' in window.navigator ? 'test' : 'skip'; From b67a9ee9873e4ce1e5c3a96a7422875f6ca70d7a Mon Sep 17 00:00:00 2001 From: mister-ben Date: Sat, 3 Jun 2023 14:23:44 +0200 Subject: [PATCH 06/15] more tests --- src/js/mediasession.js | 42 ++++++++++++++++++++------- test/unit/player-mediasession.test.js | 33 ++++++++++++++++++++- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/js/mediasession.js b/src/js/mediasession.js index c8d8e52b6a..8f9908351f 100644 --- a/src/js/mediasession.js +++ b/src/js/mediasession.js @@ -2,10 +2,8 @@ import window from 'global/window'; import {getMimetype} from './utils/mimetypes'; /** - * - * Sets up media session if supported and configured - * - * @this { import('./player').default } + * @method initMediaSession Sets up media session if supported and configured + * @this { import('./player').default } Player */ export const initMediaSession = function() { if (!this.options_.mediaSession || !('mediaSession' in window.navigator)) { @@ -25,6 +23,7 @@ export const initMediaSession = function() { this.pause(); this.currentTime(0); }], + // videojs-contrib-ads ['seekbackward', (details) => { if (this.usingPlugin('ads') && this.ads.inAdBreak()) { return; @@ -45,6 +44,9 @@ export const initMediaSession = function() { }] ]; + // Using Googles' recommendation that expects some handler may not be settable, especially as we + // want to support older Chrome + // https://web.dev/media-session/#let-users-control-whats-playing for (const [action, handler] of actionHandlers) { try { ms.setActionHandler(action, handler); @@ -74,23 +76,33 @@ export const initMediaSession = function() { } /** + * + * Updates the mediaSession metadata. Fires `updatemediasession` as an + * opportunity to modify the metadata + * * @fires Player#updatemediasession */ const updateMediaSession = () => { - this.log('updatems'); const currentMedia = this.getMedia(); const playlistItem = this.usingPlugin('playlist') ? Object.assign({}, this.playlist()[this.playlist.currentItem()]) : {}; const mediaSessionData = { - artwork: currentMedia.artwork || playlistItem.artwork || this.poster() ? [{ - src: this.poster(), - type: getMimetype(this.poster()) - }] : [], title: currentMedia.title || playlistItem.name || '', artist: currentMedia.artist || playlistItem.artist || '', album: currentMedia.album || playlistItem.album || '' }; - // This allows the metadata to be updated before being set, e.g. if loadmedia() is not used. + if (currentMedia.artwork) { + mediaSessionData.artwork = currentMedia.artwork; + } else if (playlistItem.artwork) { + mediaSessionData.artwork = playlistItem.artwork; + } else if (this.poster()) { + mediaSessionData.artwork = { + src: this.poster(), + type: getMimetype(this.poster()) + }; + } + + // This allows the metadata to be updated before being set, e.g. if loadMedia() is not used. this.trigger('updatemediasession', mediaSessionData); ms.metadata = new window.MediaMetadata(mediaSessionData); @@ -108,7 +120,15 @@ export const initMediaSession = function() { } }; - this.on('playing', updateMediaSession); + this.on('playing', () => { + updateMediaSession(); + ms.playbackState = 'playing'; + }); + + this.on(['paused', 'paused'], () => { + updateMediaSession(); + ms.playbackState = 'playing'; + }); if ('setPositionState' in ms) { this.on(['playing', 'seeked', 'ratechange'], updatePositionState); diff --git a/test/unit/player-mediasession.test.js b/test/unit/player-mediasession.test.js index 1f95cd0b0f..e39dd6647a 100644 --- a/test/unit/player-mediasession.test.js +++ b/test/unit/player-mediasession.test.js @@ -140,7 +140,7 @@ QUnit.test('mediasession data set', function(assert) { this.clock.restore(); }); -QUnit.test('mediasession can be customised befire being set', function(assert) { +QUnit.test('mediasession can be customised before being set', function(assert) { assert.expect(3); this.clock = sinon.useFakeTimers(); @@ -172,3 +172,34 @@ QUnit.test('mediasession can be customised befire being set', function(assert) { this.clock.restore(); }); +QUnit.test('action handlers set up', function(assert) { + const spy = sinon.spy(window.navigator.mediaSession, 'setActionHandler'); + + this.player = TestHelpers.makePlayer({ + mediaSession: true + }); + + assert.true(spy.calledWith('play'), 'play handler set'); + assert.false(spy.calledWith('previoustrack'), 'playlist handler not set'); + + spy.restore(); +}); + +QUnit.test('playlist action handlers set up', function(assert) { + const spy = sinon.spy(window.navigator.mediaSession, 'setActionHandler'); + + this.clock = sinon.useFakeTimers(); + this.player = TestHelpers.makePlayer({ + mediaSession: true + }); + + this.player.trigger('pluginsetup:playlist'); + + this.clock.tick(10); + + assert.true(spy.calledWith('play'), 'play handler set'); + assert.true(spy.calledWith('previoustrack'), 'playlist handler set'); + + spy.restore(); + this.clock.restore(); +}); From 2a10d09b934af0f4d82c61659676c6c9484ed345 Mon Sep 17 00:00:00 2001 From: mister-ben Date: Sat, 3 Jun 2023 15:47:26 +0200 Subject: [PATCH 07/15] always need to wait for playlist --- src/js/mediasession.js | 17 ++++++----------- ...ediasession.test.js => mediasession.test.js} | 0 2 files changed, 6 insertions(+), 11 deletions(-) rename test/unit/{player-mediasession.test.js => mediasession.test.js} (100%) diff --git a/src/js/mediasession.js b/src/js/mediasession.js index 8f9908351f..b166597a4e 100644 --- a/src/js/mediasession.js +++ b/src/js/mediasession.js @@ -44,7 +44,7 @@ export const initMediaSession = function() { }] ]; - // Using Googles' recommendation that expects some handler may not be settable, especially as we + // Using Google's recommendation that expects some handler may not be settable, especially as we // want to support older Chrome // https://web.dev/media-session/#let-users-control-whats-playing for (const [action, handler] of actionHandlers) { @@ -69,11 +69,7 @@ export const initMediaSession = function() { }; // Only setup playlist handlers if / when playlist plugin is present - if (this.usingPlugin('playlist')) { - setUpMediaSessionPlaylist(); - } else { - this.on('pluginsetup:playlist', setUpMediaSessionPlaylist); - } + this.on('pluginsetup:playlist', setUpMediaSessionPlaylist); /** * @@ -111,7 +107,7 @@ export const initMediaSession = function() { const updatePositionState = () => { const dur = parseFloat(this.duration()); - if (Number.isFinite(dur) && parseFloat(dur)) { + if (Number.isFinite(dur)) { ms.setPositionState({ duration: dur, playbackRate: this.playbackRate(), @@ -125,12 +121,11 @@ export const initMediaSession = function() { ms.playbackState = 'playing'; }); - this.on(['paused', 'paused'], () => { - updateMediaSession(); - ms.playbackState = 'playing'; + this.on('paused', () => { + ms.playbackState = 'paused'; }); if ('setPositionState' in ms) { - this.on(['playing', 'seeked', 'ratechange'], updatePositionState); + this.on('timeupdate', updatePositionState); } }; diff --git a/test/unit/player-mediasession.test.js b/test/unit/mediasession.test.js similarity index 100% rename from test/unit/player-mediasession.test.js rename to test/unit/mediasession.test.js From 8a659d2adb982ee228143f3adea9c839cf3af349 Mon Sep 17 00:00:00 2001 From: mister-ben Date: Sat, 3 Jun 2023 16:40:44 +0200 Subject: [PATCH 08/15] more tests --- src/js/mediasession.js | 6 +-- test/unit/mediasession.test.js | 83 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/js/mediasession.js b/src/js/mediasession.js index b166597a4e..5f44ba4719 100644 --- a/src/js/mediasession.js +++ b/src/js/mediasession.js @@ -64,7 +64,7 @@ export const initMediaSession = function() { this.playlist.next(); }); } catch (error) { - this.log('Couldn\'t register playlist media session actions.'); + this.log.debug('Couldn\'t register playlist media session actions.'); } }; @@ -92,10 +92,10 @@ export const initMediaSession = function() { } else if (playlistItem.artwork) { mediaSessionData.artwork = playlistItem.artwork; } else if (this.poster()) { - mediaSessionData.artwork = { + mediaSessionData.artwork = [{ src: this.poster(), type: getMimetype(this.poster()) - }; + }]; } // This allows the metadata to be updated before being set, e.g. if loadMedia() is not used. diff --git a/test/unit/mediasession.test.js b/test/unit/mediasession.test.js index e39dd6647a..7c4c58377e 100644 --- a/test/unit/mediasession.test.js +++ b/test/unit/mediasession.test.js @@ -29,6 +29,9 @@ QUnit.module('Player: MediaSession', { // }, afterEach() { this.player.dispose(); + if (this.clock) { + this.clock.restore(); + } } // , // after() { @@ -172,6 +175,65 @@ QUnit.test('mediasession can be customised before being set', function(assert) { this.clock.restore(); }); +QUnit.test('mediasession artwork', function(assert) { + assert.expect(4); + + this.clock = sinon.useFakeTimers(); + this.player = TestHelpers.makePlayer({ + mediaSession: true, + poster: 'https://example.com/poster' + }); + + this.player.getMedia = () => { + return { + artwork: [{ + src: 'https://example.com/getmedia' + }] + } + }; + + this.player.playlist = () => { + return [{ + artwork: [{ + src: 'https://example.com/playlist' + }] + }] + }; + this.player.playlist.currentItem = () => 0; + + + this.player.one('updatemediasession', (e, metadata) => { + assert.equal(metadata.artwork[0].src, 'https://example.com/getmedia', 'set with loadMedia data'); + }); + this.player.trigger('playing'); + this.clock.tick(100); + + this.player.getMedia = () => { return {}; }; + this.player.usingPlugin = () => true; + + this.player.one('updatemediasession', (e, metadata) => { + assert.equal(metadata.artwork[0].src, 'https://example.com/playlist', 'set with playlist data'); + }); + this.player.trigger('playing'); + this.clock.tick(100); + + this.player.usingPlugin = () => false; + + this.player.one('updatemediasession', (e, metadata) => { + assert.equal(metadata.artwork[0].src, 'https://example.com/poster', 'set with poster data'); + }); + this.player.trigger('playing'); + this.clock.tick(100); + + this.player.poster(null); + + this.player.one('updatemediasession', (e, metadata) => { + assert.equal(metadata.artwork, undefined, 'omitted with no data'); + }); + this.player.trigger('playing'); + +}); + QUnit.test('action handlers set up', function(assert) { const spy = sinon.spy(window.navigator.mediaSession, 'setActionHandler'); @@ -203,3 +265,24 @@ QUnit.test('playlist action handlers set up', function(assert) { spy.restore(); this.clock.restore(); }); + +QUnit.test('allows for action handlers that are not settable', function(assert) { + sinon.stub(window.navigator.mediaSession, 'setActionHandler').throws(); + + this.clock = sinon.useFakeTimers(); + this.player = TestHelpers.makePlayer({ + mediaSession: true + }); + + sinon.stub(this.player.log, 'debug'); + + this.player.trigger('pluginsetup:playlist'); + + this.clock.tick(10); + + assert.true(this.player.log.debug.calledWith('Couldn\'t register playlist media session actions.')); + + window.navigator.mediaSession.setActionHandler.restore(); + this.player.log.debug.restore(); + this.clock.restore(); +}); From 16bf58901910acb3d34ee7aa05e7e7e518c53458 Mon Sep 17 00:00:00 2001 From: mister-ben Date: Sat, 3 Jun 2023 16:44:37 +0200 Subject: [PATCH 09/15] repush From d892c1660e364f55c4209f7dbfe00024b8386b5a Mon Sep 17 00:00:00 2001 From: mister-ben Date: Sat, 3 Jun 2023 16:47:23 +0200 Subject: [PATCH 10/15] lint --- test/unit/mediasession.test.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/unit/mediasession.test.js b/test/unit/mediasession.test.js index 7c4c58377e..4c96d3daa1 100644 --- a/test/unit/mediasession.test.js +++ b/test/unit/mediasession.test.js @@ -189,7 +189,7 @@ QUnit.test('mediasession artwork', function(assert) { artwork: [{ src: 'https://example.com/getmedia' }] - } + }; }; this.player.playlist = () => { @@ -197,20 +197,21 @@ QUnit.test('mediasession artwork', function(assert) { artwork: [{ src: 'https://example.com/playlist' }] - }] + }]; }; this.player.playlist.currentItem = () => 0; - this.player.one('updatemediasession', (e, metadata) => { assert.equal(metadata.artwork[0].src, 'https://example.com/getmedia', 'set with loadMedia data'); }); this.player.trigger('playing'); this.clock.tick(100); - this.player.getMedia = () => { return {}; }; + this.player.getMedia = () => { + return {}; + }; this.player.usingPlugin = () => true; - + this.player.one('updatemediasession', (e, metadata) => { assert.equal(metadata.artwork[0].src, 'https://example.com/playlist', 'set with playlist data'); }); @@ -218,15 +219,15 @@ QUnit.test('mediasession artwork', function(assert) { this.clock.tick(100); this.player.usingPlugin = () => false; - + this.player.one('updatemediasession', (e, metadata) => { assert.equal(metadata.artwork[0].src, 'https://example.com/poster', 'set with poster data'); }); - this.player.trigger('playing'); + this.player.trigger('playing'); this.clock.tick(100); this.player.poster(null); - + this.player.one('updatemediasession', (e, metadata) => { assert.equal(metadata.artwork, undefined, 'omitted with no data'); }); @@ -273,7 +274,7 @@ QUnit.test('allows for action handlers that are not settable', function(assert) this.player = TestHelpers.makePlayer({ mediaSession: true }); - + sinon.stub(this.player.log, 'debug'); this.player.trigger('pluginsetup:playlist'); From 5314f5d961abb14941dc97361735a8d09a38c322 Mon Sep 17 00:00:00 2001 From: mister-ben Date: Sun, 4 Jun 2023 11:24:28 +0200 Subject: [PATCH 11/15] lint --- sandbox/load-media.html.example | 4 +++- src/js/mediasession.js | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/sandbox/load-media.html.example b/sandbox/load-media.html.example index f6fc119ffb..819f3dfa7d 100644 --- a/sandbox/load-media.html.example +++ b/sandbox/load-media.html.example @@ -19,7 +19,9 @@