diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /dom/media/mediacontrol/tests | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream.tar.xz firefox-esr-upstream.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'dom/media/mediacontrol/tests')
44 files changed, 5167 insertions, 0 deletions
diff --git a/dom/media/mediacontrol/tests/browser/browser.ini b/dom/media/mediacontrol/tests/browser/browser.ini new file mode 100644 index 0000000000..d6338e598b --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser.ini @@ -0,0 +1,53 @@ +[DEFAULT] +subsuite = media-bc +tags = mediacontrol +skip-if = os == "linux" # Bug 1673527 +support-files = + file_autoplay.html + file_audio_and_inaudible_media.html + file_empty_title.html + file_error_media.html + file_iframe_media.html + file_main_frame_with_multiple_child_session_frames.html + file_multiple_audible_media.html + file_muted_autoplay.html + file_no_src_media.html + file_non_autoplay.html + file_non_eligible_media.html + file_non_looping_media.html + head.js + ../../../test/bogus.ogv + ../../../test/gizmo.mp4 + ../../../test/gizmo-noaudio.webm + ../../../test/gizmo-short.mp4 + !/toolkit/components/pictureinpicture/tests/head.js + ../../../../../toolkit/content/tests/browser/silentAudioTrack.webm + +[browser_audio_focus_management.js] +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_control_page_with_audible_and_inaudible_media.js] +[browser_default_action_handler.js] +[browser_only_control_non_real_time_media.js] +[browser_media_control_audio_focus_within_a_page.js] +[browser_media_control_before_media_starts.js] +[browser_media_control_captured_audio.js] +[browser_media_control_metadata.js] +[browser_media_control_keys_event.js] +[browser_media_control_main_controller.js] +[browser_media_control_non_eligible_media.js] +skip-if = + verify && os == 'mac' # bug 1673509 +[browser_media_control_playback_state.js] +[browser_media_control_position_state.js] +[browser_media_control_seekto.js] +[browser_media_control_supported_keys.js] +[browser_media_control_stop_timer.js] +[browser_nosrc_and_error_media.js] +skip-if = + verify && os == 'mac' # bug 1673509 +[browser_seek_captured_audio.js] +[browser_stop_control_after_media_reaches_to_end.js] +[browser_suspend_inactive_tab.js] +[browser_remove_controllable_media_for_active_controller.js] +[browser_resume_latest_paused_media.js] diff --git a/dom/media/mediacontrol/tests/browser/browser_audio_focus_management.js b/dom/media/mediacontrol/tests/browser/browser_audio_focus_management.js new file mode 100644 index 0000000000..980281243d --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_audio_focus_management.js @@ -0,0 +1,179 @@ +const PAGE_AUDIBLE = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_autoplay.html"; +const PAGE_INAUDIBLE = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_muted_autoplay.html"; + +const testVideoId = "autoplay"; + +/** + * These tests are used to ensure that the audio focus management works correctly + * amongs different tabs no matter the pref is on or off. If the pref is on, + * there is only one tab which is allowed to play audio at a time, the last tab + * starting audio will immediately stop other tabs which own audio focus. But + * notice that playing inaudible media won't gain audio focus. If the pref is + * off, all audible tabs can own audio focus at the same time without + * interfering each others. + */ +add_task(async function testDisableAudioFocusManagement() { + await switchAudioFocusManagerment(false); + + info(`open audible autoplay media in tab1`); + const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId); + + info(`open same page on another tab, which shouldn't cause audio competing`); + const tab2 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId); + + info(`media in tab1 should be playing still`); + await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId); + + info(`remove tabs`); + await clearTabsAndResetPref([tab1, tab2]); +}); + +add_task(async function testEnableAudioFocusManagement() { + await switchAudioFocusManagerment(true); + + info(`open audible autoplay media in tab1`); + const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId); + + info(`open same page on another tab, which should cause audio competing`); + const tab2 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId); + + info(`media in tab1 should be stopped`); + await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId); + + info(`remove tabs`); + await clearTabsAndResetPref([tab1, tab2]); +}); + +add_task(async function testCheckAudioCompetingMultipleTimes() { + await switchAudioFocusManagerment(true); + + info(`open audible autoplay media in tab1`); + const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId); + + info(`open same page on another tab, which should cause audio competing`); + const tab2 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId); + + info(`media in tab1 should be stopped`); + await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId); + + info(`play media in tab1 again`); + await playMedia(tab1); + + info(`media in tab2 should be stopped`); + await checkOrWaitUntilMediaStoppedPlaying(tab2, testVideoId); + + info(`play media in tab2 again`); + await playMedia(tab2); + + info(`media in tab1 should be stopped`); + await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId); + + info(`remove tabs`); + await clearTabsAndResetPref([tab1, tab2]); +}); + +add_task(async function testMutedMediaWontInvolveAudioCompeting() { + await switchAudioFocusManagerment(true); + + info(`open audible autoplay media in tab1`); + const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId); + + info( + `open inaudible media page on another tab, which shouldn't cause audio competing` + ); + const tab2 = await createLoadedTabWrapper(PAGE_INAUDIBLE, { + needCheck: false, + }); + await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId); + + info(`media in tab1 should be playing still`); + await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId); + + info( + `open audible media page on the third tab, which should cause audio competing` + ); + const tab3 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab3, testVideoId); + + info(`media in tab1 should be stopped`); + await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId); + + info(`media in tab2 should not be affected because it's inaudible.`); + await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId); + + info(`remove tabs`); + await clearTabsAndResetPref([tab1, tab2, tab3]); +}); + +add_task(async function testStopMultipleTabsWhenSwitchingPrefDynamically() { + await switchAudioFocusManagerment(false); + + info(`open audible autoplay media in tab1`); + const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId); + + info(`open same page on another tab, which shouldn't cause audio competing`); + const tab2 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId); + + await switchAudioFocusManagerment(true); + + info(`open same page on the third tab, which should cause audio competing`); + const tab3 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false }); + await checkOrWaitUntilMediaStartedPlaying(tab3, testVideoId); + + info(`media in tab1 and tab2 should be stopped`); + await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId); + await checkOrWaitUntilMediaStoppedPlaying(tab2, testVideoId); + + info(`remove tabs`); + await clearTabsAndResetPref([tab1, tab2, tab3]); +}); + +/** + * The following are helper funcions. + */ +async function switchAudioFocusManagerment(enable) { + const state = enable ? "Enable" : "Disable"; + info(`${state} audio focus management`); + await SpecialPowers.pushPrefEnv({ + set: [["media.audioFocus.management", enable]], + }); +} + +async function playMedia(tab) { + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + return new Promise(resolve => { + const video = content.document.getElementById("autoplay"); + if (!video) { + ok(false, `can't get the media element!`); + } + + ok(video.paused, `media has not started yet`); + info(`wait until media starts playing`); + video.play(); + video.onplaying = () => { + video.onplaying = null; + ok(true, `media started playing`); + resolve(); + }; + }); + }); +} + +async function clearTabsAndResetPref(tabs) { + info(`clear tabs and reset pref`); + for (let tab of tabs) { + await tab.close(); + } + await switchAudioFocusManagerment(false); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_control_page_with_audible_and_inaudible_media.js b/dom/media/mediacontrol/tests/browser/browser_control_page_with_audible_and_inaudible_media.js new file mode 100644 index 0000000000..6d9bc38b04 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_control_page_with_audible_and_inaudible_media.js @@ -0,0 +1,94 @@ +const PAGE_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_audio_and_inaudible_media.html"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.testingevents.enabled", true]], + }); +}); + +/** + * When a page has audible media and inaudible media playing at the same time, + * only audible media should be controlled by media control keys. However, once + * inaudible media becomes audible, then it should be able to be controlled. + */ +add_task(async function testSetPositionState() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_URL); + + info(`play video1 (audible) and video2 (inaudible)`); + await playBothAudibleAndInaudibleMedia(tab); + + info(`pressing 'pause' should only affect video1 (audible)`); + await generateMediaControlKeyEvent("pause"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: true, + shouldVideo2BePaused: false, + }); + + info(`make video2 become audible, then it would be able to be controlled`); + await unmuteInaudibleMedia(tab); + + info(`pressing 'pause' should affect video2 (audible`); + await generateMediaControlKeyEvent("pause"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: true, + shouldVideo2BePaused: true, + }); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +async function playBothAudibleAndInaudibleMedia(tab) { + const playbackStateChangedPromise = waitUntilDisplayedPlaybackChanged(); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const videos = content.document.getElementsByTagName("video"); + let promises = []; + for (let video of videos) { + info(`play ${video.id} video`); + promises.push(video.play()); + } + return Promise.all(promises); + }); + await playbackStateChangedPromise; +} + +function checkMediaPausedState( + tab, + { shouldVideo1BePaused, shouldVideo2BePaused } +) { + return SpecialPowers.spawn( + tab.linkedBrowser, + [shouldVideo1BePaused, shouldVideo2BePaused], + (shouldVideo1BePaused, shouldVideo2BePaused) => { + const video1 = content.document.getElementById("video1"); + const video2 = content.document.getElementById("video2"); + is( + video1.paused, + shouldVideo1BePaused, + "Correct paused state for video1" + ); + is( + video2.paused, + shouldVideo2BePaused, + "Correct paused state for video2" + ); + } + ); +} + +function unmuteInaudibleMedia(tab) { + const unmutePromise = SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const video2 = content.document.getElementById("video2"); + video2.muted = false; + }); + // Inaudible media was not controllable, so it won't affect the controller's + // playback state. However, when it becomes audible, which means being able to + // be controlled by media controller, it would make the playback state chanege + // to `playing` because now we have an audible playinng media in the page. + return Promise.all([unmutePromise, waitUntilDisplayedPlaybackChanged()]); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_default_action_handler.js b/dom/media/mediacontrol/tests/browser/browser_default_action_handler.js new file mode 100644 index 0000000000..c33c08b0c2 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_default_action_handler.js @@ -0,0 +1,422 @@ +const PAGE_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; +const PAGE2_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html"; +const IFRAME_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html"; +const CORS_IFRAME_URL = + "https://example.org/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html"; +const CORS_IFRAME2_URL = + "https://test1.example.org/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html"; +const videoId = "video"; + +/** + * This test is used to check the scenario when we should use the customized + * action handler and the the default action handler (play/pause/stop). + * If a frame (DOM Window, it could be main frame or an iframe) has active media + * session, then it should use the customized action handler it it has one. + * Otherwise, the default action handler should be used. + */ +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.media.mediasession.enabled", true], + ["media.mediacontrol.testingevents.enabled", true], + ], + }); +}); + +add_task(async function triggerDefaultActionHandler() { + // Default handler should be triggered no matter if media session exists or not. + const kCreateMediaSession = [true, false]; + for (const shouldCreateSession of kCreateMediaSession) { + info(`open page and start media`); + const tab = await createLoadedTabWrapper(PAGE_URL); + await playMedia(tab, videoId); + + if (shouldCreateSession) { + info( + `media has started, so created session should become active session` + ); + await Promise.all([ + waitUntilActiveMediaSessionChanged(), + createMediaSession(tab), + ]); + } + + info(`test 'pause' action`); + await simulateMediaAction(tab, "pause"); + + info(`default action handler should pause media`); + await checkOrWaitUntilMediaPauses(tab, { videoId }); + + info(`test 'play' action`); + await simulateMediaAction(tab, "play"); + + info(`default action handler should resume media`); + await checkOrWaitUntilMediaPlays(tab, { videoId }); + + info(`test 'stop' action`); + await simulateMediaAction(tab, "stop"); + + info(`default action handler should pause media`); + await checkOrWaitUntilMediaPauses(tab, { videoId }); + + const controller = tab.linkedBrowser.browsingContext.mediaController; + ok( + !controller.isActive, + `controller should be deactivated after receiving stop` + ); + + info(`remove tab`); + await tab.close(); + } +}); + +add_task(async function triggerNonDefaultHandlerWhenSetCustomizedHandler() { + info(`open page and start media`); + const tab = await createLoadedTabWrapper(PAGE_URL); + await Promise.all([ + new Promise(r => (tab.controller.onactivated = r)), + startMedia(tab, { videoId }), + ]); + + const kActions = ["play", "pause", "stop"]; + for (const action of kActions) { + info(`set action handler for '${action}'`); + await setActionHandler(tab, action); + + info(`press '${action}' should trigger action handler (not a default one)`); + await simulateMediaAction(tab, action); + await waitUntilActionHandlerIsTriggered(tab, action); + + info(`action handler doesn't pause media, media should keep playing`); + await checkOrWaitUntilMediaPlays(tab, { videoId }); + } + + info(`remove tab`); + await tab.close(); +}); + +add_task( + async function triggerDefaultHandlerToPausePlaybackOnInactiveSession() { + const kIframeUrls = [IFRAME_URL, CORS_IFRAME_URL]; + for (const url of kIframeUrls) { + const kActions = ["play", "pause", "stop"]; + for (const action of kActions) { + info(`open page and load iframe`); + const tab = await createLoadedTabWrapper(PAGE_URL); + const frameId = "iframe"; + await loadIframe(tab, frameId, url); + + info(`start media from iframe would make it become active session`); + await Promise.all([ + new Promise(r => (tab.controller.onactivated = r)), + startMedia(tab, { frameId }), + ]); + + info(`press '${action}' should trigger iframe's action handler`); + await setActionHandler(tab, action, frameId); + await simulateMediaAction(tab, action); + await waitUntilActionHandlerIsTriggered(tab, action, frameId); + + info(`start media from main frame so iframe would become inactive`); + // When action is `play`, controller is already playing, because above + // code won't pause media. So we need to wait for the active session + // changed to ensure the following tests can be executed on the right + // browsing context. + let waitForControllerStatusChanged = + action == "play" + ? waitUntilActiveMediaSessionChanged() + : ensureControllerIsPlaying(tab.controller); + await Promise.all([ + waitForControllerStatusChanged, + startMedia(tab, { videoId }), + ]); + + if (action == "play") { + info(`pause media first in order to test 'play'`); + await pauseAllMedia(tab); + + info( + `press '${action}' would trigger default andler on main frame because it doesn't set action handler` + ); + await simulateMediaAction(tab, action); + await checkOrWaitUntilMediaPlays(tab, { videoId }); + + info( + `default handler should also be triggered on inactive iframe, which would resume media` + ); + await checkOrWaitUntilMediaPlays(tab, { frameId }); + } else { + info( + `press '${action}' would trigger default andler on main frame because it doesn't set action handler` + ); + await simulateMediaAction(tab, action); + await checkOrWaitUntilMediaPauses(tab, { videoId }); + + info( + `default handler should also be triggered on inactive iframe, which would pause media` + ); + await checkOrWaitUntilMediaPauses(tab, { frameId }); + } + + info(`remove tab`); + await tab.close(); + } + } + } +); + +add_task(async function onlyResumeActiveMediaSession() { + info(`open page and load iframes`); + const tab = await createLoadedTabWrapper(PAGE2_URL); + const frame1Id = "frame1"; + const frame2Id = "frame2"; + await loadIframe(tab, frame1Id, CORS_IFRAME_URL); + await loadIframe(tab, frame2Id, CORS_IFRAME2_URL); + + info(`start media from iframe1 would make it become active session`); + await createMediaSession(tab, frame1Id); + await Promise.all([ + waitUntilActiveMediaSessionChanged(), + startMedia(tab, { frameId: frame1Id }), + ]); + + info(`start media from iframe2 would make it become active session`); + await createMediaSession(tab, frame2Id); + await Promise.all([ + waitUntilActiveMediaSessionChanged(), + startMedia(tab, { frameId: frame2Id }), + ]); + + info(`press 'pause' should pause both iframes`); + await simulateMediaAction(tab, "pause"); + await checkOrWaitUntilMediaPauses(tab, { frameId: frame1Id }); + await checkOrWaitUntilMediaPauses(tab, { frameId: frame2Id }); + + info( + `press 'play' should only resume iframe2 which has active media session` + ); + await simulateMediaAction(tab, "play"); + await checkOrWaitUntilMediaPauses(tab, { frameId: frame1Id }); + await checkOrWaitUntilMediaPlays(tab, { frameId: frame2Id }); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +function startMedia(tab, { videoId, frameId }) { + return SpecialPowers.spawn( + tab.linkedBrowser, + [videoId, frameId], + (videoId, frameId) => { + if (frameId) { + return content.messageHelper( + content.document.getElementById(frameId), + "play", + "played" + ); + } + return content.document.getElementById(videoId).play(); + } + ); +} + +function pauseAllMedia(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + await content.messageHelper( + content.document.getElementById("iframe"), + "pause", + "paused" + ); + const videos = content.document.getElementsByTagName("video"); + for (let video of videos) { + video.pause(); + } + }); +} + +function createMediaSession(tab, frameId = null) { + info(`create media session`); + return SpecialPowers.spawn(tab.linkedBrowser, [frameId], async frameId => { + if (frameId) { + await content.messageHelper( + content.document.getElementById(frameId), + "create-media-session", + "created-media-session" + ); + return; + } + // simply calling a media session would create an instance. + content.navigator.mediaSession; + }); +} + +function checkOrWaitUntilMediaPauses(tab, { videoId, frameId }) { + return SpecialPowers.spawn( + tab.linkedBrowser, + [videoId, frameId], + (videoId, frameId) => { + if (frameId) { + return content.messageHelper( + content.document.getElementById(frameId), + "check-pause", + "checked-pause" + ); + } + return new Promise(r => { + const video = content.document.getElementById(videoId); + if (video.paused) { + ok(true, `media stopped playing`); + r(); + } else { + info(`wait until media stops playing`); + video.onpause = () => { + video.onpause = null; + ok(true, `media stopped playing`); + r(); + }; + } + }); + } + ); +} + +function checkOrWaitUntilMediaPlays(tab, { videoId, frameId }) { + return SpecialPowers.spawn( + tab.linkedBrowser, + [videoId, frameId], + (videoId, frameId) => { + if (frameId) { + return content.messageHelper( + content.document.getElementById(frameId), + "check-playing", + "checked-playing" + ); + } + return new Promise(r => { + const video = content.document.getElementById(videoId); + if (!video.paused) { + ok(true, `media is playing`); + r(); + } else { + info(`wait until media starts playing`); + video.onplay = () => { + video.onplay = null; + ok(true, `media starts playing`); + r(); + }; + } + }); + } + ); +} + +function setActionHandler(tab, action, frameId = null) { + return SpecialPowers.spawn( + tab.linkedBrowser, + [action, frameId], + async (action, frameId) => { + if (frameId) { + await content.messageHelper( + content.document.getElementById(frameId), + { + cmd: "setActionHandler", + action, + }, + "setActionHandler-done" + ); + return; + } + // Create this on the first function call + if (content.actionHandlerPromises === undefined) { + content.actionHandlerPromises = {}; + } + content.actionHandlerPromises[action] = new Promise(r => { + content.navigator.mediaSession.setActionHandler(action, () => { + info(`receive ${action}`); + r(); + }); + }); + } + ); +} + +async function waitUntilActionHandlerIsTriggered(tab, action, frameId = null) { + info(`wait until '${action}' action handler is triggered`); + return SpecialPowers.spawn( + tab.linkedBrowser, + [action, frameId], + (action, frameId) => { + if (frameId) { + return content.messageHelper( + content.document.getElementById(frameId), + { + cmd: "checkActionHandler", + action, + }, + "checkActionHandler-done" + ); + } + const actionTriggerPromise = content.actionHandlerPromises[action]; + ok(actionTriggerPromise, `Has created promise for ${action}`); + return actionTriggerPromise; + } + ); +} + +async function simulateMediaAction(tab, action) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + if (!controller.isActive) { + await new Promise(r => (controller.onactivated = r)); + } + MediaControlService.generateMediaControlKey(action); +} + +function loadIframe(tab, iframeId, url) { + return SpecialPowers.spawn( + tab.linkedBrowser, + [iframeId, url], + async (iframeId, url) => { + const iframe = content.document.getElementById(iframeId); + info(`load iframe with url '${url}'`); + iframe.src = url; + await new Promise(r => (iframe.onload = r)); + // create a helper to simplify communication process with iframe + content.messageHelper = (target, sentMessage, expectedResponse) => { + target.contentWindow.postMessage(sentMessage, "*"); + return new Promise(r => { + content.onmessage = event => { + if (event.data == expectedResponse) { + ok(true, `Received response ${expectedResponse}`); + content.onmessage = null; + r(); + } + }; + }); + }; + } + ); +} + +function waitUntilActiveMediaSessionChanged() { + return BrowserUtils.promiseObserved("active-media-session-changed"); +} + +function ensureControllerIsPlaying(controller) { + return new Promise(r => { + if (controller.isPlaying) { + r(); + return; + } + controller.onplaybackstatechange = () => { + if (controller.isPlaying) { + r(); + } + }; + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_audio_focus_within_a_page.js b/dom/media/mediacontrol/tests/browser/browser_media_control_audio_focus_within_a_page.js new file mode 100644 index 0000000000..5db37059dd --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_audio_focus_within_a_page.js @@ -0,0 +1,358 @@ +const mainPageURL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html"; +const frameURL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html"; + +const frame1 = "frame1"; +const frame2 = "frame2"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["dom.media.mediasession.enabled", true], + ], + }); +}); + +/** + * This test is used to check the behaviors when we play media from different + * frames. When a page contains multiple frames, if those frames are using the + * media session and set the metadata, then we have to know which frame owns the + * audio focus that would be the last tab playing media. When the frame owns + * audio focus, it means its metadata would be displayed on the virtual control + * interface if it has a media session. + */ +add_task(async function testAudioFocusChangesAmongMultipleFrames() { + /** + * Play the media from the main frame, so it would own the audio focus and + * its metadata should be shown on the virtual control interface. As the main + * frame doesn't use media session, the current metadata would be the default + * metadata. + */ + const tab = await createLoadedTabWrapper(mainPageURL); + await playAndWaitUntilMetadataChanged(tab); + await isGivenTabUsingDefaultMetadata(tab); + + /** + * Play media for frame1, so the audio focus switches to frame1 because it's + * the last tab playing media and frame1's metadata should be displayed. + */ + await loadPageForFrame(tab, frame1, frameURL); + let metadata = await setMetadataAndGetReturnResult(tab, frame1); + await playAndWaitUntilMetadataChanged(tab, frame1); + isCurrentMetadataEqualTo(metadata); + + /** + * Play media for frame2, so the audio focus switches to frame2 because it's + * the last tab playing media and frame2's metadata should be displayed. + */ + await loadPageForFrame(tab, frame2, frameURL); + metadata = await setMetadataAndGetReturnResult(tab, frame2); + await playAndWaitUntilMetadataChanged(tab, frame2); + isCurrentMetadataEqualTo(metadata); + + /** + * Remove tab and end test. + */ + await tab.close(); +}); + +add_task(async function testAudioFocusChangesAfterPausingAudioFocusOwner() { + /** + * Play the media from the main frame, so it would own the audio focus and + * its metadata should be shown on the virtual control interface. As the main + * frame doesn't use media session, the current metadata would be the default + * metadata. + */ + const tab = await createLoadedTabWrapper(mainPageURL); + await playAndWaitUntilMetadataChanged(tab); + await isGivenTabUsingDefaultMetadata(tab); + + /** + * Play media for frame1, so the audio focus switches to frame1 because it's + * the last tab playing media and frame1's metadata should be displayed. + */ + await loadPageForFrame(tab, frame1, frameURL); + let metadata = await setMetadataAndGetReturnResult(tab, frame1); + await playAndWaitUntilMetadataChanged(tab, frame1); + isCurrentMetadataEqualTo(metadata); + + /** + * Pause media for frame1, so the audio focus switches back to the main frame + * which is still playing media. + */ + await pauseAndWaitUntilMetadataChangedFrom(tab, frame1); + await isGivenTabUsingDefaultMetadata(tab); + + /** + * Remove tab and end test. + */ + await tab.close(); +}); + +add_task(async function testAudioFocusUnchangesAfterPausingAudioFocusOwner() { + /** + * Play the media from the main frame, so it would own the audio focus and + * its metadata should be shown on the virtual control interface. As the main + * frame doesn't use media session, the current metadata would be the default + * metadata. + */ + const tab = await createLoadedTabWrapper(mainPageURL); + await playAndWaitUntilMetadataChanged(tab); + await isGivenTabUsingDefaultMetadata(tab); + + /** + * Play media for frame1, so the audio focus switches to frame1 because it's + * the last tab playing media and frame1's metadata should be displayed. + */ + await loadPageForFrame(tab, frame1, frameURL); + let metadata = await setMetadataAndGetReturnResult(tab, frame1); + await playAndWaitUntilMetadataChanged(tab, frame1); + isCurrentMetadataEqualTo(metadata); + + /** + * Pause main frame's media first. When pausing frame1's media, there are not + * other frames playing media, so frame1 still owns the audio focus and its + * metadata should be displayed. + */ + await pauseMediaFrom(tab); + isCurrentMetadataEqualTo(metadata); + + /** + * Remove tab and end test. + */ + await tab.close(); +}); + +add_task( + async function testSwitchAudioFocusToMainFrameAfterRemovingAudioFocusOwner() { + /** + * Play the media from the main frame, so it would own the audio focus and + * its metadata should be displayed on the virtual control interface. As the + * main frame doesn't use media session, the current metadata would be the + * default metadata. + */ + const tab = await createLoadedTabWrapper(mainPageURL); + await playAndWaitUntilMetadataChanged(tab); + await isGivenTabUsingDefaultMetadata(tab); + + /** + * Play media for frame1, so the audio focus switches to frame1 because it's + * the last tab playing media and frame1's metadata should be displayed. + */ + await loadPageForFrame(tab, frame1, frameURL); + let metadata = await setMetadataAndGetReturnResult(tab, frame1); + await playAndWaitUntilMetadataChanged(tab, frame1); + isCurrentMetadataEqualTo(metadata); + + /** + * Remove frame1, the audio focus would switch to the main frame which + * metadata should be displayed. + */ + await Promise.all([ + waitUntilDisplayedMetadataChanged(), + removeFrame(tab, frame1), + ]); + await isGivenTabUsingDefaultMetadata(tab); + + /** + * Remove tab and end test. + */ + await tab.close(); + } +); + +add_task( + async function testSwitchAudioFocusToIframeAfterRemovingAudioFocusOwner() { + /** + * Play media for frame1, so frame1 owns the audio focus and frame1's metadata + * should be displayed. + */ + const tab = await createLoadedTabWrapper(mainPageURL); + await loadPageForFrame(tab, frame1, frameURL); + let metadataFrame1 = await setMetadataAndGetReturnResult(tab, frame1); + await playAndWaitUntilMetadataChanged(tab, frame1); + isCurrentMetadataEqualTo(metadataFrame1); + + /** + * Play media for frame2, so the audio focus switches to frame2 because it's + * the last tab playing media and frame2's metadata should be displayed. + */ + await loadPageForFrame(tab, frame2, frameURL); + let metadataFrame2 = await setMetadataAndGetReturnResult(tab, frame2); + await playAndWaitUntilMetadataChanged(tab, frame2); + isCurrentMetadataEqualTo(metadataFrame2); + + /** + * Remove frame2, the audio focus would switch to frame1 which metadata should + * be displayed. + */ + await Promise.all([ + waitUntilDisplayedMetadataChanged(), + removeFrame(tab, frame2), + ]); + isCurrentMetadataEqualTo(metadataFrame1); + + /** + * Remove tab and end test. + */ + await tab.close(); + } +); + +add_task(async function testNoAudioFocusAfterRemovingAudioFocusOwner() { + /** + * Play the media from the main frame, so it would own the audio focus and + * its metadata should be shown on the virtual control interface. As the main + * frame doesn't use media session, the current metadata would be the default + * metadata. + */ + const tab = await createLoadedTabWrapper(mainPageURL); + await playAndWaitUntilMetadataChanged(tab); + await isGivenTabUsingDefaultMetadata(tab); + + /** + * Play media for frame1, so the audio focus switches to frame1 because it's + * the last tab playing media and frame1's metadata should be displayed. + */ + await loadPageForFrame(tab, frame1, frameURL); + let metadata = await setMetadataAndGetReturnResult(tab, frame1); + await playAndWaitUntilMetadataChanged(tab, frame1); + isCurrentMetadataEqualTo(metadata); + + /** + * Pause media in main frame and then remove frame1. As the frame which owns + * the audio focus is removed and no frame is still playing media, the current + * metadata would be the default metadata. + */ + await pauseMediaFrom(tab); + await Promise.all([ + waitUntilDisplayedMetadataChanged(), + removeFrame(tab, frame1), + ]); + await isGivenTabUsingDefaultMetadata(tab); + + /** + * Remove tab and end test. + */ + await tab.close(); +}); + +/** + * The following are helper functions. + */ +function loadPageForFrame(tab, frameId, pageUrl) { + info(`start to load page for ${frameId}`); + return SpecialPowers.spawn( + tab.linkedBrowser, + [frameId, pageUrl], + async (id, url) => { + const iframe = content.document.getElementById(id); + if (!iframe) { + ok(false, `can not get iframe '${id}'`); + } + iframe.src = url; + await new Promise(r => (iframe.onload = r)); + // Set the document title that would be used as the value for properties + // in frame's medadata. + iframe.contentDocument.title = id; + } + ); +} + +function playMediaFrom(tab, frameId = undefined) { + return SpecialPowers.spawn(tab.linkedBrowser, [frameId], id => { + if (id == undefined) { + info(`start to play media from main frame`); + const video = content.document.getElementById("video"); + if (!video) { + ok(false, `can't get the media element!`); + } + return video.play(); + } + + info(`start to play media from ${id}`); + const iframe = content.document.getElementById(id); + if (!iframe) { + ok(false, `can not get ${id}`); + } + iframe.contentWindow.postMessage("play", "*"); + return new Promise(r => { + content.onmessage = event => { + is(event.data, "played", `media started playing in ${id}`); + r(); + }; + }); + }); +} + +function playAndWaitUntilMetadataChanged(tab, frameId = undefined) { + const metadataChanged = frameId + ? new Promise(r => (tab.controller.onmetadatachange = r)) + : waitUntilDisplayedMetadataChanged(); + return Promise.all([metadataChanged, playMediaFrom(tab, frameId)]); +} + +function pauseMediaFrom(tab, frameId = undefined) { + return SpecialPowers.spawn(tab.linkedBrowser, [frameId], id => { + if (id == undefined) { + info(`start to pause media from in frame`); + const video = content.document.getElementById("video"); + if (!video) { + ok(false, `can't get the media element!`); + } + return video.pause(); + } + + info(`start to pause media in ${id}`); + const iframe = content.document.getElementById(id); + if (!iframe) { + ok(false, `can not get ${id}`); + } + iframe.contentWindow.postMessage("pause", "*"); + return new Promise(r => { + content.onmessage = event => { + is(event.data, "paused", `media paused in ${id}`); + r(); + }; + }); + }); +} + +function pauseAndWaitUntilMetadataChangedFrom(tab, frameId = undefined) { + const metadataChanged = waitUntilDisplayedMetadataChanged(); + return Promise.all([metadataChanged, pauseMediaFrom(tab, frameId)]); +} + +function setMetadataAndGetReturnResult(tab, frameId) { + info(`start to set metadata for ${frameId}`); + return SpecialPowers.spawn(tab.linkedBrowser, [frameId], id => { + const iframe = content.document.getElementById(id); + if (!iframe) { + ok(false, `can not get ${id}`); + } + iframe.contentWindow.postMessage("setMetadata", "*"); + info(`wait until we get metadata for ${id}`); + return new Promise(r => { + content.onmessage = event => { + ok( + event.data.title && event.data.artist && event.data.album, + "correct return format" + ); + r(event.data); + }; + }); + }); +} + +function removeFrame(tab, frameId) { + info(`remove ${frameId}`); + return SpecialPowers.spawn(tab.linkedBrowser, [frameId], id => { + const iframe = content.document.getElementById(id); + if (!iframe) { + ok(false, `can not get ${id}`); + } + content.document.body.removeChild(iframe); + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_before_media_starts.js b/dom/media/mediacontrol/tests/browser/browser_media_control_before_media_starts.js new file mode 100644 index 0000000000..292c2f521f --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_before_media_starts.js @@ -0,0 +1,205 @@ +// Import this in order to use `triggerPictureInPicture()`. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js", + this +); + +const PAGE_NON_AUTOPLAY = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; +const IFRAME_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html"; +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["full-screen-api.allow-trusted-requests-only", false], + ], + }); +}); + +/** + * Usually we would only start controlling media after media starts, but if + * media has entered Picture-in-Picture mode or fullscreen, then we would allow + * users to control them directly without prior to starting media. + */ +add_task(async function testMediaEntersPIPMode() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`trigger PIP mode`); + const winPIP = await triggerPictureInPicture(tab.linkedBrowser, testVideoId); + + info(`press 'play' and wait until media starts`); + await generateMediaControlKeyEvent("play"); + await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId); + + info(`remove tab`); + await BrowserTestUtils.closeWindow(winPIP); + await tab.close(); +}); + +add_task(async function testMutedMediaEntersPIPMode() { + info(`open media page and mute video`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + await muteMedia(tab, testVideoId); + + info(`trigger PIP mode`); + const winPIP = await triggerPictureInPicture(tab.linkedBrowser, testVideoId); + + info(`press 'play' and wait until media starts`); + await generateMediaControlKeyEvent("play"); + await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId); + + info(`remove tab`); + await BrowserTestUtils.closeWindow(winPIP); + await tab.close(); +}); + +add_task(async function testMediaEntersFullScreen() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`make video fullscreen`); + await enableFullScreen(tab, testVideoId); + + info(`press 'play' and wait until media starts`); + await generateMediaControlKeyEvent("play"); + await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testMutedMediaEntersFullScreen() { + info(`open media page and mute video`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + await muteMedia(tab, testVideoId); + + info(`make video fullscreen`); + await enableFullScreen(tab, testVideoId); + + info(`press 'play' and wait until media starts`); + await generateMediaControlKeyEvent("play"); + await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testNonMediaEntersFullScreen() { + info(`open media page which won't have an activated controller`); + // As we won't activate controller in this test case, not need to + // check controller's status. + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY, { + needCheck: false, + }); + + info(`make non-media element fullscreen`); + const nonMediaElementId = "image"; + await enableFullScreen(tab, nonMediaElementId); + + info(`press 'play' which should not start media`); + // Use `generateMediaControlKey()` directly because `play` won't affect the + // controller's playback state (don't need to wait for the change). + MediaControlService.generateMediaControlKey("play"); + await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testMediaInIframeEntersFullScreen() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`make video in iframe fullscreen`); + await enableMediaFullScreenInIframe(tab, testVideoId); + + info(`press 'play' and wait until media starts`); + await generateMediaControlKeyEvent("play"); + + info(`full screen media in inframe should be started`); + await waitUntilIframeMediaStartedPlaying(tab); + + info(`media not in fullscreen should keep paused`); + await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId); + + info(`remove iframe that contains fullscreen video`); + await removeIframeFromDocument(tab); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +function muteMedia(tab, videoId) { + return SpecialPowers.spawn(tab.linkedBrowser, [videoId], videoId => { + content.document.getElementById(videoId).muted = true; + }); +} + +function enableFullScreen(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], elementId => { + return new Promise(r => { + const element = content.document.getElementById(elementId); + element.requestFullscreen(); + element.onfullscreenchange = () => { + element.onfullscreenchange = null; + element.onfullscreenerror = null; + r(); + }; + element.onfullscreenerror = () => { + // Retry until the element successfully enters fullscreen. + element.requestFullscreen(); + }; + }); + }); +} + +function enableMediaFullScreenInIframe(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [IFRAME_URL], async url => { + info(`create iframe and wait until it finishes loading`); + const iframe = content.document.getElementById("iframe"); + iframe.src = url; + await new Promise(r => (iframe.onload = r)); + + info(`trigger media in iframe entering into fullscreen`); + iframe.contentWindow.postMessage("fullscreen", "*"); + info(`wait until media in frame enters fullscreen`); + return new Promise(r => { + content.onmessage = event => { + is( + event.data, + "entered-fullscreen", + `media in iframe entered fullscreen` + ); + r(); + }; + }); + }); +} + +function waitUntilIframeMediaStartedPlaying(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [IFRAME_URL], async url => { + info(`check if media in iframe starts playing`); + const iframe = content.document.getElementById("iframe"); + iframe.contentWindow.postMessage("check-playing", "*"); + return new Promise(r => { + content.onmessage = event => { + is(event.data, "checked-playing", `media in iframe is playing`); + r(); + }; + }); + }); +} + +function removeIframeFromDocument(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], () => { + info(`remove iframe from document`); + content.document.getElementById("iframe").remove(); + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_captured_audio.js b/dom/media/mediacontrol/tests/browser/browser_media_control_captured_audio.js new file mode 100644 index 0000000000..0da8acd62b --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_captured_audio.js @@ -0,0 +1,45 @@ +const PAGE_NON_AUTOPLAY_MEDIA = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; + +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.testingevents.enabled", true]], + }); +}); + +/** + * When we capture audio from an media element to the web audio, if the media + * is audible, it should be controlled by media keys as well. + */ +add_task(async function testAudibleCapturedMedia() { + info(`open new non autoplay media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY_MEDIA); + + info(`capture audio and start playing`); + await captureAudio(tab, testVideoId); + await playMedia(tab, testVideoId); + + info(`pressing 'pause' key, captured media should be paused`); + await generateMediaControlKeyEvent("pause"); + await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +function captureAudio(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + const context = new content.AudioContext(); + // Capture audio from the media element to a MediaElementAudioSourceNode. + context.createMediaElementSource(video); + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_keys_event.js b/dom/media/mediacontrol/tests/browser/browser_media_control_keys_event.js new file mode 100644 index 0000000000..a8525e61c5 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_keys_event.js @@ -0,0 +1,62 @@ +const PAGE = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; +const testVideoId = "video"; + +/** + * This test is used to generate platform-independent media control keys event + * and see if media can be controlled correctly and current we only support + * `play`, `pause`, `playPause` and `stop` events. + */ +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.testingevents.enabled", true]], + }); +}); + +add_task(async function testPlayPauseAndStop() { + info(`open page and start media`); + const tab = await createLoadedTabWrapper(PAGE); + await playMedia(tab, testVideoId); + + info(`pressing 'pause' key`); + MediaControlService.generateMediaControlKey("pause"); + await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId); + + info(`pressing 'play' key`); + MediaControlService.generateMediaControlKey("play"); + await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId); + + info(`pressing 'stop' key`); + MediaControlService.generateMediaControlKey("stop"); + await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId); + + info(`Has stopped controlling media, pressing 'play' won't resume it`); + MediaControlService.generateMediaControlKey("play"); + await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testPlayPause() { + info(`open page and start media`); + const tab = await createLoadedTabWrapper(PAGE); + await playMedia(tab, testVideoId); + + info(`pressing 'playPause' key, media should stop`); + MediaControlService.generateMediaControlKey("playpause"); + await Promise.all([ + new Promise(r => (tab.controller.onplaybackstatechange = r)), + checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId), + ]); + + info(`pressing 'playPause' key, media should start`); + MediaControlService.generateMediaControlKey("playpause"); + await Promise.all([ + new Promise(r => (tab.controller.onplaybackstatechange = r)), + checkOrWaitUntilMediaStartedPlaying(tab, testVideoId), + ]); + + info(`remove tab`); + await tab.close(); +}); diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_main_controller.js b/dom/media/mediacontrol/tests/browser/browser_media_control_main_controller.js new file mode 100644 index 0000000000..9ddd3d7a18 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_main_controller.js @@ -0,0 +1,341 @@ +// Import this in order to use `triggerPictureInPicture()`. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js", + this +); + +const PAGE_NON_AUTOPLAY = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; + +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["dom.media.mediasession.enabled", true], + ], + }); +}); + +/** + * This test is used to check in different situaition if we can determine the + * main controller correctly that is the controller which can receive media + * control keys and show its metadata on the virtual control interface. + * + * We will assign different metadata for each tab and know which tab is the main + * controller by checking main controller's metadata. + * + * We will always choose the last tab which plays media as the main controller, + * and maintain a list by the order of playing media. If the top element in the + * list has been removed, then we will use the last element in the list as the + * main controller. + * + * Eg. tab1 plays first, then tab2 plays, then tab3 plays, the list would be + * like [tab1, tab2, tab3] and the main controller would be tab3. If tab3 has + * been closed, then the list would become [tab1, tab2] and the tab2 would be + * the main controller. + */ +add_task(async function testDeterminingMainController() { + info(`open three different tabs`); + const tab0 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + const tab2 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + /** + * part1 : [] -> [tab0] -> [tab0, tab1] -> [tab0, tab1, tab2] + */ + info(`# [] -> [tab0] -> [tab0, tab1] -> [tab0, tab1, tab2] #`); + info(`set different metadata for each tab`); + await setMediaMetadataForTabs([tab0, tab1, tab2]); + + info(`start media for tab0, main controller should become tab0`); + await makeTabBecomeMainControllerAndWaitForMetadataChange(tab0); + + info(`currrent metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab0.metadata); + + info(`start media for tab1, main controller should become tab1`); + await makeTabBecomeMainControllerAndWaitForMetadataChange(tab1); + + info(`currrent metadata should be equal to tab1's metadata`); + await isCurrentMetadataEqualTo(tab1.metadata); + + info(`start media for tab2, main controller should become tab2`); + await makeTabBecomeMainControllerAndWaitForMetadataChange(tab2); + + info(`currrent metadata should be equal to tab2's metadata`); + await isCurrentMetadataEqualTo(tab2.metadata); + + /** + * part2 : [tab0, tab1, tab2] -> [tab0, tab2, tab1] -> [tab2, tab1, tab0] + */ + info(`# [tab0, tab1, tab2] -> [tab0, tab2, tab1] -> [tab2, tab1, tab0] #`); + info(`start media for tab1, main controller should become tab1`); + await makeTabBecomeMainController(tab1); + + info(`currrent metadata should be equal to tab1's metadata`); + await isCurrentMetadataEqualTo(tab1.metadata); + + info(`start media for tab0, main controller should become tab0`); + await makeTabBecomeMainController(tab0); + + info(`currrent metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab0.metadata); + + /** + * part3 : [tab2, tab1, tab0] -> [tab2, tab1] -> [tab2] -> [] + */ + info(`# [tab2, tab1, tab0] -> [tab2, tab1] -> [tab2] -> [] #`); + info(`remove tab0 and wait until main controller changes`); + await Promise.all([waitUntilMainMediaControllerChanged(), tab0.close()]); + + info(`currrent metadata should be equal to tab1's metadata`); + await isCurrentMetadataEqualTo(tab1.metadata); + + info(`remove tab1 and wait until main controller changes`); + await Promise.all([waitUntilMainMediaControllerChanged(), tab1.close()]); + + info(`currrent metadata should be equal to tab2's metadata`); + await isCurrentMetadataEqualTo(tab2.metadata); + + info(`remove tab2 and wait until main controller changes`); + await Promise.all([waitUntilMainMediaControllerChanged(), tab2.close()]); + isCurrentMetadataEmpty(); +}); + +add_task(async function testPIPControllerWontBeReplacedByNormalController() { + info(`open two different tabs`); + const tab0 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`set different metadata for each tab`); + await setMediaMetadataForTabs([tab0, tab1]); + + info(`start media for tab0, main controller should become tab0`); + await makeTabBecomeMainControllerAndWaitForMetadataChange(tab0); + + info(`currrent metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab0.metadata); + + info(`trigger Picture-in-Picture mode for tab0`); + const winPIP = await triggerPictureInPicture(tab0.linkedBrowser, testVideoId); + + info(`start media for tab1, main controller should still be tab0`); + await playMediaAndWaitUntilRegisteringController(tab1, testVideoId); + + info(`currrent metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab0.metadata); + + info(`remove tab0 and wait until main controller changes`); + await BrowserTestUtils.closeWindow(winPIP); + await Promise.all([waitUntilMainMediaControllerChanged(), tab0.close()]); + + info(`currrent metadata should be equal to tab1's metadata`); + await isCurrentMetadataEqualTo(tab1.metadata); + + info(`remove tab1 and wait until main controller changes`); + await Promise.all([waitUntilMainMediaControllerChanged(), tab1.close()]); + isCurrentMetadataEmpty(); +}); + +add_task( + async function testFullscreenControllerWontBeReplacedByNormalController() { + info(`open two different tabs`); + const tab0 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`set different metadata for each tab`); + await setMediaMetadataForTabs([tab0, tab1]); + + info(`start media for tab0, main controller should become tab0`); + await makeTabBecomeMainControllerAndWaitForMetadataChange(tab0); + + info(`current metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab0.metadata); + + info(`video in tab0 enters fullscreen`); + await switchTabToForegroundAndEnableFullScreen(tab0, testVideoId); + + info( + `normal controller won't become the main controller, ` + + `which is still fullscreen controller` + ); + await playMediaAndWaitUntilRegisteringController(tab1, testVideoId); + + info(`currrent metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab0.metadata); + + info(`remove tabs`); + await Promise.all([tab0.close(), tab1.close()]); + } +); + +add_task(async function testFullscreenAndPIPControllers() { + info(`open three different tabs`); + const tab0 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + const tab2 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`set different metadata for each tab`); + await setMediaMetadataForTabs([tab0, tab1, tab2]); + + /** + * Current controller list : [tab0 (fullscreen)] + */ + info(`start media for tab0, main controller should become tab0`); + await makeTabBecomeMainControllerAndWaitForMetadataChange(tab0); + + info(`currrent metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab0.metadata); + + info(`video in tab0 enters fullscreen`); + await switchTabToForegroundAndEnableFullScreen(tab0, testVideoId); + + /** + * Current controller list : [tab1, tab0 (fullscreen)] + */ + info(`start media for tab1, main controller should still be tab0`); + await playMediaAndWaitUntilRegisteringController(tab1, testVideoId); + + info(`currrent metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab0.metadata); + + /** + * Current controller list : [tab0 (fullscreen), tab1 (PIP)] + */ + info(`tab1 enters PIP so tab1 should become new main controller`); + const mainControllerChange = waitUntilMainMediaControllerChanged(); + const winPIP = await triggerPictureInPicture(tab1.linkedBrowser, testVideoId); + await mainControllerChange; + + info(`currrent metadata should be equal to tab1's metadata`); + await isCurrentMetadataEqualTo(tab1.metadata); + + /** + * Current controller list : [tab2, tab0 (fullscreen), tab1 (PIP)] + */ + info(`play video from tab2 which shouldn't affect main controller`); + await playMediaAndWaitUntilRegisteringController(tab2, testVideoId); + + /** + * Current controller list : [tab2, tab0 (fullscreen)] + */ + info(`remove tab1 and wait until main controller changes`); + await BrowserTestUtils.closeWindow(winPIP); + await Promise.all([waitUntilMainMediaControllerChanged(), tab1.close()]); + + info(`currrent metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab0.metadata); + + /** + * Current controller list : [tab2] + */ + info(`remove tab0 and wait until main controller changes`); + await Promise.all([waitUntilMainMediaControllerChanged(), tab0.close()]); + + info(`currrent metadata should be equal to tab0's metadata`); + await isCurrentMetadataEqualTo(tab2.metadata); + + /** + * Current controller list : [] + */ + info(`remove tab2 and wait until main controller changes`); + await Promise.all([waitUntilMainMediaControllerChanged(), tab2.close()]); + isCurrentMetadataEmpty(); +}); + +/** + * The following are helper functions + */ +async function setMediaMetadataForTabs(tabs) { + for (let idx = 0; idx < tabs.length; idx++) { + const tabName = "tab" + idx; + info(`create metadata for ${tabName}`); + tabs[idx].metadata = { + title: tabName, + artist: tabName, + album: tabName, + artwork: [{ src: tabName, sizes: "128x128", type: "image/jpeg" }], + }; + const spawn = SpecialPowers.spawn( + tabs[idx].linkedBrowser, + [tabs[idx].metadata], + data => { + content.navigator.mediaSession.metadata = new content.MediaMetadata( + data + ); + } + ); + // As those controller hasn't been activated yet, we can't listen to + // `mediacontroll.onmetadatachange`, which would only be notified after a + // controller becomes active. + await Promise.all([spawn, waitUntilControllerMetadataChanged()]); + } +} + +function makeTabBecomeMainController(tab) { + const playPromise = SpecialPowers.spawn( + tab.linkedBrowser, + [testVideoId], + async Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + // If media has been started, we would stop media first and then start it + // again, which would make controller's playback state change to `playing` + // again and result in updating new main controller. + if (!video.paused) { + video.pause(); + info(`wait until media stops`); + await new Promise(r => (video.onpause = r)); + } + info(`start media`); + return video.play(); + } + ); + return Promise.all([playPromise, waitUntilMainMediaControllerChanged()]); +} + +function makeTabBecomeMainControllerAndWaitForMetadataChange(tab) { + return Promise.all([ + new Promise(r => (tab.controller.onmetadatachange = r)), + makeTabBecomeMainController(tab), + ]); +} + +function playMediaAndWaitUntilRegisteringController(tab, elementId) { + const playPromise = SpecialPowers.spawn( + tab.linkedBrowser, + [elementId], + Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + return video.play(); + } + ); + return Promise.all([waitUntilMediaControllerAmountChanged(), playPromise]); +} + +async function switchTabToForegroundAndEnableFullScreen(tab, elementId) { + // Fullscreen can only be allowed to enter from a focus tab. + await BrowserTestUtils.switchTab(gBrowser, tab.tabElement); + await SpecialPowers.spawn(tab.linkedBrowser, [elementId], elementId => { + return new Promise(r => { + const element = content.document.getElementById(elementId); + element.requestFullscreen(); + element.onfullscreenchange = () => { + element.onfullscreenchange = null; + element.onfullscreenerror = null; + r(); + }; + element.onfullscreenerror = () => { + // Retry until the element successfully enters fullscreen. + element.requestFullscreen(); + }; + }); + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_metadata.js b/dom/media/mediacontrol/tests/browser/browser_media_control_metadata.js new file mode 100644 index 0000000000..75a0d80ac9 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_metadata.js @@ -0,0 +1,416 @@ +const PAGE_NON_AUTOPLAY = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; +const PAGE_EMPTY_TITLE_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_empty_title.html"; + +const testVideoId = "video"; +const defaultFaviconName = "defaultFavicon.svg"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["dom.media.mediasession.enabled", true], + ], + }); +}); + +/** + * This test includes many different test cases of checking the current media + * metadata from the tab which is being controlled by the media control. Each + * `add_task(..)` is a different testing scenario. + * + * Media metadata is the information that can tell user about what title, artist, + * album and even art work the tab is currently playing to. The metadta is + * usually set from MediaSession API, but if the site doesn't use that API, we + * would also generate a default metadata instead. + * + * The current metadata would only be available after the page starts playing + * media at least once, if the page hasn't started any media before, then the + * current metadata is always empty. + * + * For following situations, we would create a default metadata which title is + * website's title and artwork is from our default favicon icon. + * (1) the page doesn't use MediaSession API + * (2) media session doesn't has metadata + * (3) media session has an empty metadata + * + * Otherwise, the current metadata would be media session's metadata from the + * tab which is currently controlled by the media control. + */ +add_task(async function testDefaultMetadataForPageWithoutMediaSession() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`should use default metadata because of lacking of media session`); + await isGivenTabUsingDefaultMetadata(tab); + + info(`remove tab`); + await tab.close(); +}); + +add_task( + async function testDefaultMetadataForEmptyTitlePageWithoutMediaSession() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_EMPTY_TITLE_URL); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`should use default metadata because of lacking of media session`); + await isGivenTabUsingDefaultMetadata(tab); + + info(`remove tab`); + await tab.close(); + } +); + +add_task(async function testDefaultMetadataForPageUsingEmptyMetadata() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`create empty media metadata`); + await setMediaMetadata(tab, { + title: "", + artist: "", + album: "", + artwork: [], + }); + + info(`should use default metadata because of empty media metadata`); + await isGivenTabUsingDefaultMetadata(tab); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testDefaultMetadataForPageUsingNullMetadata() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`create empty media metadata`); + await setNullMediaMetadata(tab); + + info(`should use default metadata because of lacking of media metadata`); + await isGivenTabUsingDefaultMetadata(tab); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testMetadataWithEmptyTitleAndArtwork() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`create media metadata with empty title and artwork`); + await setMediaMetadata(tab, { + title: "", + artist: "foo", + album: "bar", + artwork: [], + }); + + info(`should use default metadata because of empty title and artwork`); + await isGivenTabUsingDefaultMetadata(tab); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testMetadataWithoutTitleAndArtwork() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`create media metadata with empty title and artwork`); + await setMediaMetadata(tab, { + artist: "foo", + album: "bar", + }); + + info(`should use default metadata because of lacking of title and artwork`); + await isGivenTabUsingDefaultMetadata(tab); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testMetadataInPrivateBrowsing() { + info(`create a private window`); + const inputWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY, { inputWindow }); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`set metadata`); + let metadata = { + title: "foo", + artist: "bar", + album: "foo", + artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }], + }; + await setMediaMetadata(tab, metadata); + + info(`should use default metadata because of in private browsing mode`); + await isGivenTabUsingDefaultMetadata(tab, { isPrivateBrowsing: true }); + + info(`remove tab`); + await tab.close(); + + info(`close private window`); + await BrowserTestUtils.closeWindow(inputWindow); +}); + +add_task(async function testSetMetadataFromMediaSessionAPI() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`set metadata`); + let metadata = { + title: "foo", + artist: "bar", + album: "foo", + artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }], + }; + await setMediaMetadata(tab, metadata); + + info(`check if current active metadata is equal to what we've set before`); + await isCurrentMetadataEqualTo(metadata); + + info(`set metadata again to see if current metadata would change`); + metadata = { + title: "foo2", + artist: "bar2", + album: "foo2", + artwork: [{ src: "bar2.jpg", sizes: "129x129", type: "image/jpeg" }], + }; + await setMediaMetadata(tab, metadata); + + info(`check if current active metadata is equal to what we've set before`); + await isCurrentMetadataEqualTo(metadata); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testSetMetadataBeforeMediaStarts() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY, { + needCheck: false, + }); + + info(`set metadata`); + let metadata = { + title: "foo", + artist: "bar", + album: "foo", + artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }], + }; + await setMediaMetadata(tab, metadata, { notExpectChange: true }); + + info(`current media metadata should be empty before media starts`); + isCurrentMetadataEmpty(); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testSetMetadataAfterMediaPaused() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media in order to let this tab be controlled`); + await playMedia(tab, testVideoId); + + info(`pause media`); + await pauseMedia(tab, testVideoId); + + info(`set metadata after media is paused`); + let metadata = { + title: "foo", + artist: "bar", + album: "foo", + artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }], + }; + await setMediaMetadata(tab, metadata); + + info(`check if current active metadata is equal to what we've set before`); + await isCurrentMetadataEqualTo(metadata); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testSetMetadataAmongMultipleTabs() { + info(`open media page in tab1`); + const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media in tab1`); + await playMedia(tab1, testVideoId); + + info(`set metadata for tab1`); + let metadata = { + title: "foo", + artist: "bar", + album: "foo", + artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }], + }; + await setMediaMetadata(tab1, metadata); + + info(`check if current active metadata is equal to what we've set before`); + await isCurrentMetadataEqualTo(metadata); + + info(`open another page in tab2`); + const tab2 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media in tab2`); + await playMedia(tab2, testVideoId); + + info(`set metadata for tab2`); + metadata = { + title: "foo2", + artist: "bar2", + album: "foo2", + artwork: [{ src: "bar2.jpg", sizes: "129x129", type: "image/jpeg" }], + }; + await setMediaMetadata(tab2, metadata); + + info(`current active metadata should become metadata from tab2`); + await isCurrentMetadataEqualTo(metadata); + + info( + `update metadata for tab1, which should not affect current metadata ` + + `because media session in tab2 is the one we're controlling right now` + ); + await setMediaMetadata(tab1, { + title: "foo3", + artist: "bar3", + album: "foo3", + artwork: [{ src: "bar3.jpg", sizes: "130x130", type: "image/jpeg" }], + }); + + info(`current active metadata should still be metadata from tab2`); + await isCurrentMetadataEqualTo(metadata); + + info(`remove tabs`); + await Promise.all([tab1.close(), tab2.close()]); +}); + +add_task(async function testMetadataAfterTabNavigation() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`set metadata`); + let metadata = { + title: "foo", + artist: "bar", + album: "foo", + artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }], + }; + await setMediaMetadata(tab, metadata); + + info(`check if current active metadata is equal to what we've set before`); + await isCurrentMetadataEqualTo(metadata); + + info(`navigate tab to blank page`); + await Promise.all([ + new Promise(r => (tab.controller.ondeactivated = r)), + BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:blank"), + ]); + + info(`current media metadata should be reset`); + isCurrentMetadataEmpty(); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testUpdateDefaultMetadataWhenPageTitleChanges() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`should use default metadata because of lacking of media session`); + await isGivenTabUsingDefaultMetadata(tab); + + info(`default metadata should be updated after page title changes`); + await changePageTitle(tab, { shouldAffectMetadata: true }); + await isGivenTabUsingDefaultMetadata(tab); + + info(`after setting metadata, title change won't affect current metadata`); + const metadata = { + title: "foo", + artist: "bar", + album: "foo", + artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }], + }; + await setMediaMetadata(tab, metadata); + await changePageTitle(tab, { shouldAffectMetadata: false }); + await isCurrentMetadataEqualTo(metadata); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +function setMediaMetadata(tab, metadata, { notExpectChange } = {}) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + const metadatachangePromise = notExpectChange + ? Promise.resolve() + : new Promise(r => (controller.onmetadatachange = r)); + return Promise.all([ + metadatachangePromise, + SpecialPowers.spawn(tab.linkedBrowser, [metadata], data => { + content.navigator.mediaSession.metadata = new content.MediaMetadata(data); + }), + ]); +} + +function setNullMediaMetadata(tab) { + const promise = SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.navigator.mediaSession.metadata = null; + }); + return Promise.all([promise, waitUntilControllerMetadataChanged()]); +} + +async function changePageTitle(tab, { shouldAffectMetadata } = {}) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + const shouldWaitMetadataChangePromise = shouldAffectMetadata + ? new Promise(r => (controller.onmetadatachange = r)) + : Promise.resolve(); + await Promise.all([ + shouldWaitMetadataChangePromise, + SpecialPowers.spawn(tab.linkedBrowser, [], _ => { + content.document.title = "new title"; + }), + ]); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_non_eligible_media.js b/dom/media/mediacontrol/tests/browser/browser_media_control_non_eligible_media.js new file mode 100644 index 0000000000..ab18eab634 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_non_eligible_media.js @@ -0,0 +1,204 @@ +const PAGE_NON_ELIGIBLE_MEDIA = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_eligible_media.html"; + +// Import this in order to use `triggerPictureInPicture()`. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js", + this +); + +// Bug 1673509 - This test requests a lot of fullscreen for media elements, +// which sometime Gecko would take longer time to fulfill. +requestLongerTimeout(2); + +// This array contains the elements' id in `file_non_eligible_media.html`. +const gNonEligibleElementIds = [ + "muted", + "volume-0", + "silent-audio-track", + "no-audio-track", + "short-duration", + "inaudible-captured-media", +]; + +/** + * This test is used to test couples of things about what kinds of media is + * eligible for being controlled by media control keys. + * (1) If media is inaudible all the time, then we would not control it. + * (2) If media starts inaudibly, we would not try to control it. But once it + * becomes audible later, we would keep controlling it until it's destroyed. + * (3) If media's duration is too short (<3s), then we would not control it. + */ +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.testingevents.enabled", true]], + }); +}); + +add_task( + async function testNonAudibleMediaCantActivateControllerButAudibleMediaCan() { + for (const elementId of gNonEligibleElementIds) { + info(`open new tab with non eligible media elements`); + const tab = await createLoadedTabWrapper(PAGE_NON_ELIGIBLE_MEDIA, { + needCheck: couldElementBecomeEligible(elementId), + }); + + info(`although media is playing but it won't activate controller`); + await Promise.all([ + startNonEligibleMedia(tab, elementId), + checkIfMediaIsStillPlaying(tab, elementId), + ]); + ok(!tab.controller.isActive, "controller is still inactive"); + + if (couldElementBecomeEligible(elementId)) { + info(`make element ${elementId} audible would activate controller`); + await Promise.all([ + makeElementEligible(tab, elementId), + checkOrWaitUntilControllerBecomeActive(tab), + ]); + } + + info(`remove tab`); + await tab.close(); + } + } +); + +/** + * Normally those media are not able to being controlled, however, once they + * enter fullsceen or Picture-in-Picture mode, then they can be controlled. + */ +add_task(async function testNonEligibleMediaEnterFullscreen() { + info(`open new tab with non eligible media elements`); + const tab = await createLoadedTabWrapper(PAGE_NON_ELIGIBLE_MEDIA); + + for (const elementId of gNonEligibleElementIds) { + await startNonEligibleMedia(tab, elementId); + + info(`entering fullscreen should activate the media controller`); + await enterFullScreen(tab, elementId); + await checkOrWaitUntilControllerBecomeActive(tab); + ok(true, `fullscreen ${elementId} media is able to being controlled`); + + info(`leave fullscreen`); + await leaveFullScreen(tab); + } + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testNonEligibleMediaEnterPIPMode() { + info(`open new tab with non eligible media elements`); + const tab = await createLoadedTabWrapper(PAGE_NON_ELIGIBLE_MEDIA); + + for (const elementId of gNonEligibleElementIds) { + await startNonEligibleMedia(tab, elementId); + + info(`media entering PIP mode should activate the media controller`); + const winPIP = await triggerPictureInPicture(tab.linkedBrowser, elementId); + await checkOrWaitUntilControllerBecomeActive(tab); + ok(true, `PIP ${elementId} media is able to being controlled`); + + info(`stop PIP mode`); + await BrowserTestUtils.closeWindow(winPIP); + } + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +function startNonEligibleMedia(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + if (Id == "volume-0") { + video.volume = 0.0; + } + if (Id == "inaudible-captured-media") { + const context = new content.AudioContext(); + context.createMediaElementSource(video); + } + info(`start non eligible media ${Id}`); + return video.play(); + }); +} + +function checkIfMediaIsStillPlaying(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + return new Promise(r => { + // In order to test "media isn't affected by media control", we would not + // only check `mPaused`, we would also oberve "timeupdate" event multiple + // times to ensure that video is still playing continually. + let timeUpdateCount = 0; + ok(!video.paused); + video.ontimeupdate = () => { + if (++timeUpdateCount == 3) { + video.ontimeupdate = null; + r(); + } + }; + }); + }); +} + +function couldElementBecomeEligible(elementId) { + return elementId == "muted" || elementId == "volume-0"; +} + +function makeElementEligible(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + // to turn inaudible media become audible in order to be controlled. + video.volume = 1.0; + video.muted = false; + }); +} + +function waitUntilMediaPaused(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + if (video.paused) { + ok(true, "media has been paused"); + return Promise.resolve(); + } + return new Promise(r => (video.onpaused = r)); + }); +} + +function enterFullScreen(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + return new Promise(r => { + const element = content.document.getElementById(Id); + element.requestFullscreen(); + element.onfullscreenchange = () => { + element.onfullscreenchange = null; + element.onfullscreenerror = null; + r(); + }; + element.onfullscreenerror = () => { + // Retry until the element successfully enters fullscreen. + element.requestFullscreen(); + }; + }); + }); +} + +function leaveFullScreen(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], _ => { + return content.document.exitFullscreen(); + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_playback_state.js b/dom/media/mediacontrol/tests/browser/browser_media_control_playback_state.js new file mode 100644 index 0000000000..4d30566e0a --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_playback_state.js @@ -0,0 +1,116 @@ +const PAGE_NON_AUTOPLAY = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; + +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["dom.media.mediasession.enabled", true], + ], + }); +}); + +/** + * This test is used to check the actual playback state [1] of the main media + * controller. The declared playback state is the playback state from the active + * media session, and the guessed playback state is determined by the media's + * playback state. Both the declared playback and the guessed playback state + * would be used to decide the final result of the actual playback state. + * + * [1] https://w3c.github.io/mediasession/#actual-playback-state + */ +add_task(async function testDefaultPlaybackStateBeforeAnyMediaStart() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY, { + needCheck: false, + }); + + info(`before media starts, playback state should be 'none'`); + await isActualPlaybackStateEqualTo(tab, "none"); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testGuessedPlaybackState() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info( + `Now declared='none', guessed='playing', so actual playback state should be 'playing'` + ); + await setGuessedPlaybackState(tab, "playing"); + await isActualPlaybackStateEqualTo(tab, "playing"); + + info( + `Now declared='none', guessed='paused', so actual playback state should be 'paused'` + ); + await setGuessedPlaybackState(tab, "paused"); + await isActualPlaybackStateEqualTo(tab, "paused"); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testBothGuessedAndDeclaredPlaybackState() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info( + `Now declared='paused', guessed='playing', so actual playback state should be 'playing'` + ); + await setDeclaredPlaybackState(tab, "paused"); + await setGuessedPlaybackState(tab, "playing"); + await isActualPlaybackStateEqualTo(tab, "playing"); + + info( + `Now declared='paused', guessed='paused', so actual playback state should be 'paused'` + ); + await setGuessedPlaybackState(tab, "paused"); + await isActualPlaybackStateEqualTo(tab, "paused"); + + info( + `Now declared='playing', guessed='paused', so actual playback state should be 'playing'` + ); + await setDeclaredPlaybackState(tab, "playing"); + await isActualPlaybackStateEqualTo(tab, "playing"); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +function setGuessedPlaybackState(tab, state) { + if (state == "playing") { + return playMedia(tab, testVideoId); + } else if (state == "paused") { + return pauseMedia(tab, testVideoId); + } + // We won't set the state `stopped`, which would only happen if no any media + // has ever been started in the page. + ok(false, `should only set 'playing' or 'paused' state`); + return Promise.resolve(); +} + +async function isActualPlaybackStateEqualTo(tab, expectedState) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + if (controller.playbackState != expectedState) { + await new Promise(r => (controller.onplaybackstatechange = r)); + } + is( + controller.playbackState, + expectedState, + `current state '${controller.playbackState}' is equal to '${expectedState}'` + ); +} + +function setDeclaredPlaybackState(tab, state) { + return SpecialPowers.spawn(tab.linkedBrowser, [state], playbackState => { + info(`set declared playback state to '${playbackState}'`); + content.navigator.mediaSession.playbackState = playbackState; + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_position_state.js b/dom/media/mediacontrol/tests/browser/browser_media_control_position_state.js new file mode 100644 index 0000000000..f32ce26063 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_position_state.js @@ -0,0 +1,150 @@ +const PAGE_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; +const IFRAME_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html"; + +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["dom.media.mediasession.enabled", true], + ], + }); +}); + +/** + * This test is used to check if we can receive correct position state change, + * when we set the position state to the media session. + */ +add_task(async function testSetPositionState() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_URL); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`set duration only`); + await setPositionState(tab, { + duration: 60, + }); + + info(`set duration and playback rate`); + await setPositionState(tab, { + duration: 50, + playbackRate: 2.0, + }); + + info(`set duration, playback rate and position`); + await setPositionState(tab, { + duration: 40, + playbackRate: 3.0, + position: 10, + }); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testSetPositionStateFromInactiveMediaSession() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_URL); + + info(`start media`); + await playMedia(tab, testVideoId); + + info( + `add an event listener to measure how many times the position state changes` + ); + let positionChangedNum = 0; + const controller = tab.linkedBrowser.browsingContext.mediaController; + controller.onpositionstatechange = () => positionChangedNum++; + + info(`set position state on the main page which has an active media session`); + await setPositionState(tab, { + duration: 60, + }); + + info(`set position state on the iframe which has an inactive media session`); + await setPositionStateOnInactiveMediaSession(tab); + + info(`set position state on the main page again`); + await setPositionState(tab, { + duration: 60, + }); + is( + positionChangedNum, + 2, + `We should only receive two times of position change, because ` + + `the second one which performs on inactive media session is effectless` + ); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +async function setPositionState(tab, positionState) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + const positionStateChanged = new Promise(r => { + controller.addEventListener( + "positionstatechange", + event => { + const { duration, playbackRate, position } = positionState; + // duration is mandatory. + is( + event.duration, + duration, + `expected duration ${event.duration} is equal to ${duration}` + ); + + // Playback rate is optional, if it's not present, default should be 1.0 + if (playbackRate) { + is( + event.playbackRate, + playbackRate, + `expected playbackRate ${event.playbackRate} is equal to ${playbackRate}` + ); + } else { + is(event.playbackRate, 1.0, `expected default playbackRate is 1.0`); + } + + // Position state is optional, if it's not present, default should be 0.0 + if (position) { + is( + event.position, + position, + `expected position ${event.position} is equal to ${position}` + ); + } else { + is(event.position, 0.0, `expected default position is 0.0`); + } + r(); + }, + { once: true } + ); + }); + await SpecialPowers.spawn( + tab.linkedBrowser, + [positionState], + positionState => { + content.navigator.mediaSession.setPositionState(positionState); + } + ); + await positionStateChanged; +} + +async function setPositionStateOnInactiveMediaSession(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [IFRAME_URL], async url => { + info(`create iframe and wait until it finishes loading`); + const iframe = content.document.getElementById("iframe"); + iframe.src = url; + await new Promise(r => (iframe.onload = r)); + + info(`trigger media in iframe entering into fullscreen`); + iframe.contentWindow.postMessage("setPositionState", "*"); + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_seekto.js b/dom/media/mediacontrol/tests/browser/browser_media_control_seekto.js new file mode 100644 index 0000000000..70b75841b6 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_seekto.js @@ -0,0 +1,89 @@ +const PAGE_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; + +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["dom.media.mediasession.enabled", true], + ], + }); +}); + +/** + * This test is used to check if the `seekto` action can be sent when calling + * media controller's `seekTo()`. In addition, the seeking related properties + * which would be sent to the action handler should also be correct as what we + * set in `seekTo()`. + */ +add_task(async function testSetPositionState() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_URL); + + info(`start media`); + await playMedia(tab, testVideoId); + + const seektime = 0; + info(`seek to ${seektime} seconds.`); + await PerformSeekTo(tab, { + seekTime: seektime, + }); + + info(`seek to ${seektime} seconds and set fastseek to boolean`); + await PerformSeekTo(tab, { + seekTime: seektime, + fastSeek: true, + }); + + info(`seek to ${seektime} seconds and set fastseek to false`); + await PerformSeekTo(tab, { + seekTime: seektime, + fastSeek: false, + }); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following is helper function. + */ +async function PerformSeekTo(tab, seekDetails) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [seekDetails, testVideoId], + (seekDetails, Id) => { + const { seekTime, fastSeek } = seekDetails; + content.navigator.mediaSession.setActionHandler("seekto", details => { + ok(details.seekTime != undefined, "Seektime must be presented"); + is(seekTime, details.seekTime, "Get correct seektime"); + if (fastSeek) { + is(fastSeek, details.fastSeek, "Get correct fastSeek"); + } else { + ok( + details.fastSeek === undefined, + "Details should not contain fastSeek" + ); + } + // We use `onseek` as a hint to know if the `seekto` has been received + // or not. The reason we don't return a resolved promise instead is + // because if we do so, it can't guarantees that the `seekto` action + // handler has been set before calling `mediaController.seekTo`. + content.document.getElementById(Id).currentTime = seekTime; + }); + } + ); + const seekPromise = SpecialPowers.spawn( + tab.linkedBrowser, + [testVideoId], + Id => { + const video = content.document.getElementById(Id); + return new Promise(r => (video.onseeking = r())); + } + ); + const { seekTime, fastSeek } = seekDetails; + tab.linkedBrowser.browsingContext.mediaController.seekTo(seekTime, fastSeek); + await seekPromise; +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_stop_timer.js b/dom/media/mediacontrol/tests/browser/browser_media_control_stop_timer.js new file mode 100644 index 0000000000..34fc10badd --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_stop_timer.js @@ -0,0 +1,79 @@ +// Import this in order to use `triggerPictureInPicture()`. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js", + this +); + +const PAGE_NON_AUTOPLAY = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; + +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["media.mediacontrol.stopcontrol.timer", true], + ["media.mediacontrol.stopcontrol.timer.ms", 0], + ], + }); +}); + +/** + * This test is used to check the stop timer for media element, which would stop + * media control for the specific element when the element has been paused over + * certain length of time. (That is controlled by the value of the pref + * `media.mediacontrol.stopcontrol.timer.ms`) In this test, we set the pref to 0 + * which means the stop timer would be triggered after the media is paused. + * However, if the media is being used in PIP mode, we won't start the stop + * timer for it. + */ +add_task(async function testStopMediaControlAfterPausingMedia() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`pause media and the stop timer would stop media control`); + await pauseMediaAndMediaControlShouldBeStopped(tab, testVideoId); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testNotToStopMediaControlForPIPVideo() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`trigger PIP mode`); + const winPIP = await triggerPictureInPicture(tab.linkedBrowser, testVideoId); + + info(`pause media and the stop timer would not stop media control`); + await pauseMedia(tab, testVideoId); + + info(`pressing 'play' key should start PIP video again`); + await generateMediaControlKeyEvent("play"); + await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId); + + info(`remove tab`); + await BrowserTestUtils.closeWindow(winPIP); + await tab.close(); +}); + +/** + * The following is helper function. + */ +function pauseMediaAndMediaControlShouldBeStopped(tab, elementId) { + // After pausing media, the stop timer would be triggered and stop the media + // control. + return Promise.all([ + new Promise(r => (tab.controller.ondeactivated = r)), + SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + content.document.getElementById(Id).pause(); + }), + ]); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_supported_keys.js b/dom/media/mediacontrol/tests/browser/browser_media_control_supported_keys.js new file mode 100644 index 0000000000..2312e90c88 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_supported_keys.js @@ -0,0 +1,130 @@ +const PAGE_NON_AUTOPLAY = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; + +const testVideoId = "video"; +const sDefaultSupportedKeys = ["focus", "play", "pause", "playpause", "stop"]; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["dom.media.mediasession.enabled", true], + ], + }); +}); + +/** + * Supported media keys are used for indicating what UI button should be shown + * on the virtual control interface. All supported media keys are listed in + * `MediaKey` in `MediaController.webidl`. Some media keys are defined as + * default media keys which are always supported. Otherwise, other media keys + * have to have corresponding action handler on the active media session in + * order to be added to the supported keys. + */ +add_task(async function testDefaultSupportedKeys() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`should use default supported keys`); + await supportedKeysShouldEqualTo(tab, sDefaultSupportedKeys); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testNoActionHandlerBeingSet() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`create media session but not set any action handler`); + await setMediaSessionSupportedAction(tab, []); + + info( + `should use default supported keys even if ` + + `media session doesn't have any action handler` + ); + await supportedKeysShouldEqualTo(tab, sDefaultSupportedKeys); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testSettingActionsWhichAreAlreadyDefaultKeys() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`create media session but not set any action handler`); + await setMediaSessionSupportedAction(tab, ["play", "pause", "stop"]); + + info( + `those actions has already been included in default supported keys, so ` + + `the result should still be default supported keys` + ); + await supportedKeysShouldEqualTo(tab, sDefaultSupportedKeys); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testSettingActionsWhichAreNotDefaultKeys() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY); + + info(`start media`); + await playMedia(tab, testVideoId); + + info(`create media session but not set any action handler`); + let nonDefaultActions = [ + "seekbackward", + "seekforward", + "previoustrack", + "nexttrack", + ]; + await setMediaSessionSupportedAction(tab, nonDefaultActions); + + info( + `supported keys should include those actions which are not default supported keys` + ); + let expectedKeys = sDefaultSupportedKeys.concat(nonDefaultActions); + await supportedKeysShouldEqualTo(tab, expectedKeys); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +async function supportedKeysShouldEqualTo(tab, expectedKeys) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + const supportedKeys = controller.supportedKeys; + while (JSON.stringify(expectedKeys) != JSON.stringify(supportedKeys)) { + await new Promise(r => (controller.onsupportedkeyschange = r)); + } + for (let idx = 0; idx < expectedKeys.length; idx++) { + is( + supportedKeys[idx], + expectedKeys[idx], + `'${supportedKeys[idx]}' should equal to '${expectedKeys[idx]}'` + ); + } +} + +function setMediaSessionSupportedAction(tab, actions) { + return SpecialPowers.spawn(tab.linkedBrowser, [actions], actionArr => { + for (let action of actionArr) { + content.navigator.mediaSession.setActionHandler(action, () => { + info(`set '${action}' action handler`); + }); + } + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_nosrc_and_error_media.js b/dom/media/mediacontrol/tests/browser/browser_nosrc_and_error_media.js new file mode 100644 index 0000000000..837b0ecda6 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_nosrc_and_error_media.js @@ -0,0 +1,102 @@ +// Import this in order to use `triggerPictureInPicture()`. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js", + this +); + +const PAGE_NOSRC_MEDIA = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_no_src_media.html"; +const PAGE_ERROR_MEDIA = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_error_media.html"; +const PAGES = [PAGE_NOSRC_MEDIA, PAGE_ERROR_MEDIA]; +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.testingevents.enabled", true]], + }); +}); + +/** + * To ensure the no src media and media with error won't activate the media + * controller even if they enters PIP mode or fullscreen. + */ +add_task(async function testNoSrcOrErrorMediaEntersPIPMode() { + for (let page of PAGES) { + info(`open media page ${page}`); + const tab = await createLoadedTabWrapper(page, { needCheck: false }); + + info(`controller should always inactive`); + const controller = tab.linkedBrowser.browsingContext.mediaController; + controller.onactivated = () => { + ok(false, "should not get activated!"); + }; + + info(`enter PIP mode which would not affect controller`); + const winPIP = await triggerPictureInPicture( + tab.linkedBrowser, + testVideoId + ); + info(`leave PIP mode`); + await ensureMessageAndClosePiP( + tab.linkedBrowser, + testVideoId, + winPIP, + false + ); + ok(!controller.isActive, "controller is still inactive"); + + info(`remove tab`); + await tab.close(); + } +}); + +add_task(async function testNoSrcOrErrorMediaEntersFullscreen() { + for (let page of PAGES) { + info(`open media page ${page}`); + const tab = await createLoadedTabWrapper(page, { needCheck: false }); + + info(`controller should always inactive`); + const controller = tab.linkedBrowser.browsingContext.mediaController; + controller.onactivated = () => { + ok(false, "should not get activated!"); + }; + + info(`enter and leave fullscreen which would not affect controller`); + await enterAndLeaveFullScreen(tab, testVideoId); + ok(!controller.isActive, "controller is still inactive"); + + info(`remove tab`); + await tab.close(); + } +}); + +/** + * The following is helper function. + */ +async function enterAndLeaveFullScreen(tab, elementId) { + await new Promise(resolve => + SimpleTest.waitForFocus(resolve, tab.linkedBrowser.ownerGlobal) + ); + await SpecialPowers.spawn(tab.linkedBrowser, [elementId], elementId => { + return new Promise(r => { + const element = content.document.getElementById(elementId); + ok(!content.document.fullscreenElement, "no fullscreen element"); + element.requestFullscreen(); + element.onfullscreenchange = () => { + if (content.document.fullscreenElement) { + element.onfullscreenerror = null; + content.document.exitFullscreen(); + } else { + element.onfullscreenchange = null; + element.onfullscreenerror = null; + r(); + } + }; + element.onfullscreenerror = () => { + // Retry until the element successfully enters fullscreen. + element.requestFullscreen(); + }; + }); + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_only_control_non_real_time_media.js b/dom/media/mediacontrol/tests/browser/browser_only_control_non_real_time_media.js new file mode 100644 index 0000000000..76cf8b0ffd --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_only_control_non_real_time_media.js @@ -0,0 +1,76 @@ +const PAGE_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_empty_title.html"; + +/** + * This test is used to ensure that real-time media won't be affected by the + * media control. Only non-real-time media would. + */ +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.testingevents.enabled", true]], + }); +}); + +add_task(async function testOnlyControlNonRealTimeMedia() { + const tab = await createLoadedTabWrapper(PAGE_URL); + const controller = tab.linkedBrowser.browsingContext.mediaController; + await StartRealTimeMedia(tab); + ok( + !controller.isActive, + "starting a real-time media won't acivate controller" + ); + + info(`playing a non-real-time media would activate controller`); + await Promise.all([ + new Promise(r => (controller.onactivated = r)), + startNonRealTimeMedia(tab), + ]); + + info(`'pause' action should only pause non-real-time media`); + MediaControlService.generateMediaControlKey("pause"); + await new Promise(r => (controller.onplaybackstatechange = r)); + await checkIfMediaAreAffectedByMediaControl(tab); + + info(`remove tab`); + await tab.close(); +}); + +async function startNonRealTimeMedia(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => { + let video = content.document.getElementById("video"); + if (!video) { + ok(false, `can not get the video element!`); + return; + } + await video.play(); + }); +} + +async function StartRealTimeMedia(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => { + let videoRealTime = content.document.createElement("video"); + content.document.body.appendChild(videoRealTime); + videoRealTime.srcObject = await content.navigator.mediaDevices.getUserMedia( + { audio: true, fake: true } + ); + // We want to ensure that the checking of should the media be controlled by + // media control would be performed after the element finishes loading the + // media stream. Using `autoplay` would trigger the play invocation only + // after the element get enough data. + videoRealTime.autoplay = true; + await new Promise(r => (videoRealTime.onplaying = r)); + }); +} + +async function checkIfMediaAreAffectedByMediaControl(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => { + const vids = content.document.getElementsByTagName("video"); + for (let vid of vids) { + if (!vid.srcObject) { + ok(vid.paused, "non-real-time media should be paused"); + } else { + ok(!vid.paused, "real-time media should not be affected"); + } + } + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_remove_controllable_media_for_active_controller.js b/dom/media/mediacontrol/tests/browser/browser_remove_controllable_media_for_active_controller.js new file mode 100644 index 0000000000..a0aa5af4e9 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_remove_controllable_media_for_active_controller.js @@ -0,0 +1,108 @@ +const PAGE_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; + +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.testingevents.enabled", true]], + }); +}); + +/** + * If an active controller has an active media session, then it can still be + * controlled via media key even if there is no controllable media presents. + * As active media session could still create other controllable media in its + * action handler, it should still receive media key. However, if a controller + * doesn't have an active media session, then it won't be controlled via media + * key when no controllable media presents. + */ +add_task( + async function testControllerWithActiveMediaSessionShouldStillBeActiveWhenNoControllableMediaPresents() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_URL); + + info(`play media would activate controller and media session`); + await setupMediaSession(tab); + await playMedia(tab, testVideoId); + await checkOrWaitControllerBecomesActive(tab); + + info(`remove playing media so we don't have any controllable media now`); + await Promise.all([ + new Promise(r => (tab.controller.onplaybackstatechange = r)), + removePlayingMedia(tab), + ]); + + info(`despite that, controller should still be active`); + await checkOrWaitControllerBecomesActive(tab); + + info(`active media session can still receive media key`); + await ensureActiveMediaSessionReceivedMediaKey(tab); + + info(`remove tab`); + await tab.close(); + } +); + +add_task( + async function testControllerWithoutActiveMediaSessionShouldBecomeInactiveWhenNoControllableMediaPresents() { + info(`open media page`); + const tab = await createLoadedTabWrapper(PAGE_URL); + + info(`play media would activate controller`); + await playMedia(tab, testVideoId); + await checkOrWaitControllerBecomesActive(tab); + + info( + `remove playing media so we don't have any controllable media, which would deactivate controller` + ); + await Promise.all([ + new Promise(r => (tab.controller.ondeactivated = r)), + removePlayingMedia(tab), + ]); + + info(`remove tab`); + await tab.close(); + } +); + +/** + * The following are helper functions. + */ +function setupMediaSession(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], _ => { + // except `play/pause/stop`, set an action handler for arbitrary key in + // order to later verify if the session receives that media key by listening + // to session's `onpositionstatechange`. + content.navigator.mediaSession.setActionHandler("seekforward", _ => { + content.navigator.mediaSession.setPositionState({ + duration: 60, + }); + }); + }); +} + +async function ensureActiveMediaSessionReceivedMediaKey(tab) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + const positionChangePromise = new Promise( + r => (controller.onpositionstatechange = r) + ); + MediaControlService.generateMediaControlKey("seekforward"); + await positionChangePromise; + ok(true, "active media session received media key"); +} + +function removePlayingMedia(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [testVideoId], Id => { + content.document.getElementById(Id).remove(); + }); +} + +async function checkOrWaitControllerBecomesActive(tab) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + if (!controller.isActive) { + info(`wait until controller gets activated`); + await new Promise(r => (controller.onactivated = r)); + } + ok(controller.isActive, `controller is active`); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_resume_latest_paused_media.js b/dom/media/mediacontrol/tests/browser/browser_resume_latest_paused_media.js new file mode 100644 index 0000000000..58cd3f5a0f --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_resume_latest_paused_media.js @@ -0,0 +1,189 @@ +const PAGE_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_multiple_audible_media.html"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.testingevents.enabled", true]], + }); +}); + +/** + * This test is used to check when resuming media, we would only resume latest + * paused media, not all media in the page. + */ +add_task(async function testResumingLatestPausedMedias() { + info(`open media page and play all media`); + const tab = await createLoadedTabWrapper(PAGE_URL); + await playAllMedia(tab); + + /** + * Pressing `pause` key would pause video1, video2, video3 + * So resuming from media control key would affect those three media + */ + info(`pressing 'pause' should pause all media`); + await generateMediaControlKeyEvent("pause"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: true, + shouldVideo2BePaused: true, + shouldVideo3BePaused: true, + }); + + info(`all media are latest paused, pressing 'play' should resume all`); + await generateMediaControlKeyEvent("play"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: false, + shouldVideo2BePaused: false, + shouldVideo3BePaused: false, + }); + + info(`pause only one playing video by calling its webidl method`); + await pauseMedia(tab, "video3"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: false, + shouldVideo2BePaused: false, + shouldVideo3BePaused: true, + }); + + /** + * Pressing `pause` key would pause video1, video2 + * So resuming from media control key would affect those two media + */ + info(`pressing 'pause' should pause two playing media`); + await generateMediaControlKeyEvent("pause"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: true, + shouldVideo2BePaused: true, + shouldVideo3BePaused: true, + }); + + info(`two media are latest paused, pressing 'play' should only affect them`); + await generateMediaControlKeyEvent("play"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: false, + shouldVideo2BePaused: false, + shouldVideo3BePaused: true, + }); + + info(`pause only one playing video by calling its webidl method`); + await pauseMedia(tab, "video2"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: false, + shouldVideo2BePaused: true, + shouldVideo3BePaused: true, + }); + + /** + * Pressing `pause` key would pause video1 + * So resuming from media control key would only affect one media + */ + info(`pressing 'pause' should pause one playing media`); + await generateMediaControlKeyEvent("pause"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: true, + shouldVideo2BePaused: true, + shouldVideo3BePaused: true, + }); + + info(`one media is latest paused, pressing 'play' should only affect it`); + await generateMediaControlKeyEvent("play"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: false, + shouldVideo2BePaused: true, + shouldVideo3BePaused: true, + }); + + /** + * Only one media is playing, so pausing it should not stop controlling media. + * We should still be able to resume it later. + */ + info(`pause only playing video by calling its webidl method`); + await pauseMedia(tab, "video1"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: true, + shouldVideo2BePaused: true, + shouldVideo3BePaused: true, + }); + + info(`pressing 'pause' for already paused media, nothing would happen`); + // All media are already paused, so no need to wait for playback state change, + // call the method directly. + MediaControlService.generateMediaControlKey("pause"); + + info(`pressing 'play' would still affect on latest paused media`); + await generateMediaControlKeyEvent("play"); + await checkMediaPausedState(tab, { + shouldVideo1BePaused: false, + shouldVideo2BePaused: true, + shouldVideo3BePaused: true, + }); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +async function playAllMedia(tab) { + const playbackStateChangedPromise = waitUntilDisplayedPlaybackChanged(); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + return new Promise(r => { + const videos = content.document.getElementsByTagName("video"); + let mediaCount = 0; + docShell.chromeEventHandler.addEventListener( + "MozStartMediaControl", + () => { + if (++mediaCount == videos.length) { + info(`all media have started media control`); + r(); + } + } + ); + for (let video of videos) { + info(`play ${video.id} video`); + video.play(); + } + }); + }); + await playbackStateChangedPromise; +} + +async function pauseMedia(tab, videoId) { + await SpecialPowers.spawn(tab.linkedBrowser, [videoId], videoId => { + const video = content.document.getElementById(videoId); + if (!video) { + ok(false, `can not find ${videoId}!`); + } + video.pause(); + }); +} + +function checkMediaPausedState( + tab, + { shouldVideo1BePaused, shouldVideo2BePaused, shouldVideo3BePaused } +) { + return SpecialPowers.spawn( + tab.linkedBrowser, + [shouldVideo1BePaused, shouldVideo2BePaused, shouldVideo3BePaused], + (shouldVideo1BePaused, shouldVideo2BePaused, shouldVideo3BePaused) => { + const video1 = content.document.getElementById("video1"); + const video2 = content.document.getElementById("video2"); + const video3 = content.document.getElementById("video3"); + is( + video1.paused, + shouldVideo1BePaused, + "Correct paused state for video1" + ); + is( + video2.paused, + shouldVideo2BePaused, + "Correct paused state for video2" + ); + is( + video3.paused, + shouldVideo3BePaused, + "Correct paused state for video3" + ); + } + ); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_seek_captured_audio.js b/dom/media/mediacontrol/tests/browser/browser_seek_captured_audio.js new file mode 100644 index 0000000000..3dbbee065e --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_seek_captured_audio.js @@ -0,0 +1,59 @@ +const PAGE_NON_AUTOPLAY_MEDIA = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; + +const testVideoId = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.testingevents.enabled", true]], + }); +}); + +/** + * Seeking a captured audio media before it starts, and it should still be able + * to be controlled via media key after it starts playing. + */ +add_task(async function testSeekAudibleCapturedMedia() { + info(`open new non autoplay media page`); + const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY_MEDIA); + + info(`perform seek on the captured media before it starts`); + await captureAudio(tab, testVideoId); + await seekAudio(tab, testVideoId); + + info(`start captured media`); + await playMedia(tab, testVideoId); + + info(`pressing 'pause' key, captured media should be paused`); + await generateMediaControlKeyEvent("pause"); + await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +function captureAudio(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + const context = new content.AudioContext(); + // Capture audio from the media element to a MediaElementAudioSourceNode. + context.createMediaElementSource(video); + }); +} + +function seekAudio(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], async Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + video.currentTime = 0.0; + await new Promise(r => (video.onseeked = r)); + }); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_stop_control_after_media_reaches_to_end.js b/dom/media/mediacontrol/tests/browser/browser_stop_control_after_media_reaches_to_end.js new file mode 100644 index 0000000000..cc8ccf270a --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_stop_control_after_media_reaches_to_end.js @@ -0,0 +1,108 @@ +const PAGE_URL = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_looping_media.html"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [["media.mediacontrol.stopcontrol.aftermediaends", true]], + }); +}); + +/** + * This test is used to ensure that we would stop controlling media after it + * reaches to the end when a controller doesn't have an active media session. + * If a controller has an active media session, it would keep active despite + * media reaches to the end. + */ +add_task(async function testControllerShouldStopAfterMediaReachesToTheEnd() { + info(`open media page and play media until the end`); + const tab = await createLoadedTabWrapper(PAGE_URL); + await Promise.all([ + checkIfMediaControllerBecomeInactiveAfterMediaEnds(tab), + playMediaUntilItReachesToTheEnd(tab), + ]); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testControllerWontStopAfterMediaReachesToTheEnd() { + info(`open media page and create media session`); + const tab = await createLoadedTabWrapper(PAGE_URL); + await createMediaSession(tab); + + info(`play media until the end`); + await playMediaUntilItReachesToTheEnd(tab); + + info(`controller is still active because of having active media session`); + await checkControllerIsActive(tab); + + info(`remove tab`); + await tab.close(); +}); + +/** + * The following are helper functions. + */ +function checkIfMediaControllerBecomeInactiveAfterMediaEnds(tab) { + return new Promise(r => { + let activeChangedNums = 0; + const controller = tab.linkedBrowser.browsingContext.mediaController; + controller.onactivated = () => { + is( + ++activeChangedNums, + 1, + `Receive ${activeChangedNums} times 'onactivechange'` + ); + // We activate controller when it becomes playing, which doesn't guarantee + // it's already audible, so we won't check audible state here. + ok(controller.isActive, "controller should be active"); + ok(controller.isPlaying, "controller should be playing"); + }; + controller.ondeactivated = () => { + is( + ++activeChangedNums, + 2, + `Receive ${activeChangedNums} times 'onactivechange'` + ); + ok(!controller.isActive, "controller should be inactive"); + ok(!controller.isAudible, "controller should be inaudible"); + ok(!controller.isPlaying, "controller should be paused"); + r(); + }; + }); +} + +function playMediaUntilItReachesToTheEnd(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + const video = content.document.getElementById("video"); + if (!video) { + ok(false, "can't get video"); + } + + if (video.readyState < video.HAVE_METADATA) { + info(`load media to get its duration`); + video.load(); + await new Promise(r => (video.loadedmetadata = r)); + } + + info(`adjust the start position to faster reach to the end`); + ok(video.duration > 1.0, "video's duration is larger than 1.0s"); + video.currentTime = video.duration - 1.0; + + info(`play ${video.id} video`); + video.play(); + await new Promise(r => (video.onended = r)); + }); +} + +function createMediaSession(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], _ => { + // simply create a media session, which would become the active media session later. + content.navigator.mediaSession; + }); +} + +function checkControllerIsActive(tab) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + ok(controller.isActive, `controller is active`); +} diff --git a/dom/media/mediacontrol/tests/browser/browser_suspend_inactive_tab.js b/dom/media/mediacontrol/tests/browser/browser_suspend_inactive_tab.js new file mode 100644 index 0000000000..334717a2f2 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_suspend_inactive_tab.js @@ -0,0 +1,131 @@ +const PAGE_NON_AUTOPLAY = + "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html"; +const VIDEO_ID = "video"; + +add_task(async function setupTestingPref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.mediacontrol.testingevents.enabled", true], + ["dom.suspend_inactive.enabled", true], + ["dom.audiocontext.testing", true], + ], + }); +}); + +/** + * This test to used to test the feature that would suspend the inactive tab, + * which currently is only used on Android. + * + * Normally when tab becomes inactive, we would suspend it and stop its script + * from running. However, if a tab has a main controller, which indicates it + * might have playng media, or waiting media keys to control media, then it + * would not be suspended event if it's inactive. + * + * In addition, Note that, on Android, audio focus management is enabled by + * default, so there is only one tab being able to play at a time, which means + * the tab playing media always has main controller. + */ +add_task(async function testInactiveTabWouldBeSuspended() { + info(`open a tab`); + const tab = await createTab(PAGE_NON_AUTOPLAY); + await assertIfWindowGetSuspended(tab, { shouldBeSuspended: false }); + + info(`tab should be suspended when it becomes inactive`); + setTabActive(tab, false); + await assertIfWindowGetSuspended(tab, { shouldBeSuspended: true }); + + info(`remove tab`); + await tab.close(); +}); + +add_task(async function testInactiveTabEverStartPlayingWontBeSuspended() { + info(`open tab1 and play media`); + const tab1 = await createTab(PAGE_NON_AUTOPLAY, { needCheck: true }); + await assertIfWindowGetSuspended(tab1, { shouldBeSuspended: false }); + await playMedia(tab1, VIDEO_ID); + + info(`tab with playing media won't be suspended when it becomes inactive`); + setTabActive(tab1, false); + await assertIfWindowGetSuspended(tab1, { shouldBeSuspended: false }); + + info( + `even if media is paused, keep tab running so that it could listen to media keys to control media in the future` + ); + await pauseMedia(tab1, VIDEO_ID); + await assertIfWindowGetSuspended(tab1, { shouldBeSuspended: false }); + + info(`open tab2 and play media`); + const tab2 = await createTab(PAGE_NON_AUTOPLAY, { needCheck: true }); + await assertIfWindowGetSuspended(tab2, { shouldBeSuspended: false }); + await playMedia(tab2, VIDEO_ID); + + info( + `as inactive tab1 doesn't own main controller, it should be suspended again` + ); + await assertIfWindowGetSuspended(tab1, { shouldBeSuspended: true }); + + info(`remove tabs`); + await Promise.all([tab1.close(), tab2.close()]); +}); + +add_task( + async function testInactiveTabWithRunningAudioContextWontBeSuspended() { + info(`open tab and start an audio context (AC)`); + const tab = await createTab("about:blank"); + await startAudioContext(tab); + await assertIfWindowGetSuspended(tab, { shouldBeSuspended: false }); + + info(`tab with running AC won't be suspended when it becomes inactive`); + setTabActive(tab, false); + await assertIfWindowGetSuspended(tab, { shouldBeSuspended: false }); + + info(`if AC has been suspended, then inactive tab should be suspended`); + await suspendAudioContext(tab); + await assertIfWindowGetSuspended(tab, { shouldBeSuspended: true }); + + info(`remove tab`); + await tab.close(); + } +); + +/** + * The following are helper functions. + */ +async function createTab(url, needCheck = false) { + const tab = await createLoadedTabWrapper(url, { needCheck }); + return tab; +} + +function assertIfWindowGetSuspended(tab, { shouldBeSuspended }) { + return SpecialPowers.spawn( + tab.linkedBrowser, + [shouldBeSuspended], + expectedSuspend => { + const isSuspended = content.windowUtils.suspendedByBrowsingContextGroup; + is( + expectedSuspend, + isSuspended, + `window suspended state (${isSuspended}) is equal to the expected` + ); + } + ); +} + +function setTabActive(tab, isActive) { + tab.linkedBrowser.docShellIsActive = isActive; +} + +function startAudioContext(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => { + content.ac = new content.AudioContext(); + await new Promise(r => (content.ac.onstatechange = r)); + ok(content.ac.state == "running", `Audio context started running`); + }); +} + +function suspendAudioContext(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => { + await content.ac.suspend(); + ok(content.ac.state == "suspended", `Audio context is suspended`); + }); +} diff --git a/dom/media/mediacontrol/tests/browser/file_audio_and_inaudible_media.html b/dom/media/mediacontrol/tests/browser/file_audio_and_inaudible_media.html new file mode 100644 index 0000000000..e16d5dee26 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_audio_and_inaudible_media.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> + <title>page with audible and inaudible media</title> +</head> +<body> +<video id="video1" src="gizmo.mp4" loop></video> +<video id="video2" src="gizmo.mp4" loop muted></video> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_autoplay.html b/dom/media/mediacontrol/tests/browser/file_autoplay.html new file mode 100644 index 0000000000..97a58ec2a2 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_autoplay.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> + <title>Autoplay page</title> +</head> +<body> +<video id="autoplay" src="gizmo.mp4" autoplay loop></video> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_empty_title.html b/dom/media/mediacontrol/tests/browser/file_empty_title.html new file mode 100644 index 0000000000..516c16036f --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_empty_title.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> +<title></title> +</head> +<body> +<video id="video" src="gizmo.mp4" loop></video> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_error_media.html b/dom/media/mediacontrol/tests/browser/file_error_media.html new file mode 100644 index 0000000000..7f54340dd1 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_error_media.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> +<title>Error media</title> +</head> +<body> +<video id="video" src="bogus.ogv"></video> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_iframe_media.html b/dom/media/mediacontrol/tests/browser/file_iframe_media.html new file mode 100644 index 0000000000..2d2c4fd122 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_iframe_media.html @@ -0,0 +1,94 @@ +<!DOCTYPE html> +<html> +<head> +</head> +<body> +<video id="video" src="gizmo.mp4" loop></video> +<script type="text/javascript"> + +const video = document.getElementById("video"); +const w = window.opener || window.parent; + +window.onmessage = async event => { + if (event.data == "fullscreen") { + video.requestFullscreen(); + video.onfullscreenchange = () => { + video.onfullscreenchange = null; + video.onfullscreenerror = null; + w.postMessage("entered-fullscreen", "*"); + } + video.onfullscreenerror = () => { + // Retry until the element successfully enters fullscreen. + video.requestFullscreen(); + } + } else if (event.data == "check-playing") { + if (!video.paused) { + w.postMessage("checked-playing", "*"); + } else { + video.onplaying = () => { + video.onplaying = null; + w.postMessage("checked-playing", "*"); + } + } + } else if (event.data == "check-pause") { + if (video.paused) { + w.postMessage("checked-pause", "*"); + } else { + video.onpause = () => { + video.onpause = null; + w.postMessage("checked-pause", "*"); + } + } + } else if (event.data == "play") { + await video.play(); + w.postMessage("played", "*"); + } else if (event.data == "pause") { + video.pause(); + w.postMessage("paused", "*"); + } else if (event.data == "setMetadata") { + const metadata = { + title: document.title, + artist: document.title, + album: document.title, + artwork: [{ src: document.title, sizes: "128x128", type: "image/jpeg" }], + }; + navigator.mediaSession.metadata = new window.MediaMetadata(metadata); + w.postMessage(metadata, "*"); + } else if (event.data == "setPositionState") { + navigator.mediaSession.setPositionState({ + duration: 60, // The value doesn't matter + }); + } else if (event.data.cmd == "setActionHandler") { + if (window.triggeredActionHandler === undefined) { + window.triggeredActionHandler = {}; + } + const action = event.data.action; + window.triggeredActionHandler[action] = new Promise(r => { + navigator.mediaSession.setActionHandler(action, async () => { + if (action == "stop" || action == "pause") { + video.pause(); + } else if (action == "play") { + await video.play(); + } + r(); + }); + }); + w.postMessage("setActionHandler-done", "*"); + } else if (event.data.cmd == "checkActionHandler") { + const action = event.data.action; + if (!window.triggeredActionHandler[action]) { + w.postMessage("checkActionHandler-fail", "*"); + } else { + await window.triggeredActionHandler[action]; + w.postMessage("checkActionHandler-done", "*"); + } + } else if (event.data == "create-media-session") { + // simply calling a media session would create an instance. + navigator.mediaSession; + w.postMessage("created-media-session", "*"); + } +} + +</script> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html b/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html new file mode 100644 index 0000000000..f8e7aa9afe --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> +<title>Media control page with multiple iframes which contain media session</title> +</head> +<body> +<video id="video" src="gizmo.mp4" loop></video> +<iframe id="frame1"></iframe> +<iframe id="frame2"></iframe> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_multiple_audible_media.html b/dom/media/mediacontrol/tests/browser/file_multiple_audible_media.html new file mode 100644 index 0000000000..e78fabe7fa --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_multiple_audible_media.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> + <title>mutiple audible media</title> +</head> +<body> +<video id="video1" src="gizmo.mp4" loop></video> +<video id="video2" src="gizmo.mp4" loop></video> +<video id="video3" src="gizmo.mp4" loop></video> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_muted_autoplay.html b/dom/media/mediacontrol/tests/browser/file_muted_autoplay.html new file mode 100644 index 0000000000..f64d537a46 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_muted_autoplay.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> + <title>Muted autoplay page</title> +</head> +<body> +<video id="autoplay" src="gizmo.mp4" autoplay loop muted></video> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_no_src_media.html b/dom/media/mediacontrol/tests/browser/file_no_src_media.html new file mode 100644 index 0000000000..e1318e863c --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_no_src_media.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> +<title>No src media</title> +</head> +<body> +<video id="video"></video> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_non_autoplay.html b/dom/media/mediacontrol/tests/browser/file_non_autoplay.html new file mode 100644 index 0000000000..06daa7e2d8 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_non_autoplay.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> +<title>Non-Autoplay page</title> +</head> +<body> +<video id="video" src="gizmo.mp4" loop></video> +<image id="image" src="data:image/svg+xml;base64,PCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGZpbGw9ImNvbnRleHQtZmlsbCIgZmlsbC1vcGFjaXR5PSJjb250ZXh0LWZpbGwtb3BhY2l0eSIgZD0iTTggMGE4IDggMCAxIDAgOCA4IDguMDA5IDguMDA5IDAgMCAwLTgtOHptNS4xNjMgNC45NThoLTEuNTUyYTcuNyA3LjcgMCAwIDAtMS4wNTEtMi4zNzYgNi4wMyA2LjAzIDAgMCAxIDIuNjAzIDIuMzc2ek0xNCA4YTUuOTYzIDUuOTYzIDAgMCAxLS4zMzUgMS45NThoLTEuODIxQTEyLjMyNyAxMi4zMjcgMCAwIDAgMTIgOGExMi4zMjcgMTIuMzI3IDAgMCAwLS4xNTYtMS45NThoMS44MjFBNS45NjMgNS45NjMgMCAwIDEgMTQgOHptLTYgNmMtMS4wNzUgMC0yLjAzNy0xLjItMi41NjctMi45NThoNS4xMzVDMTAuMDM3IDEyLjggOS4wNzUgMTQgOCAxNHpNNS4xNzQgOS45NThhMTEuMDg0IDExLjA4NCAwIDAgMSAwLTMuOTE2aDUuNjUxQTExLjExNCAxMS4xMTQgMCAwIDEgMTEgOGExMS4xMTQgMTEuMTE0IDAgMCAxLS4xNzQgMS45NTh6TTIgOGE1Ljk2MyA1Ljk2MyAwIDAgMSAuMzM1LTEuOTU4aDEuODIxYTEyLjM2MSAxMi4zNjEgMCAwIDAgMCAzLjkxNkgyLjMzNUE1Ljk2MyA1Ljk2MyAwIDAgMSAyIDh6bTYtNmMxLjA3NSAwIDIuMDM3IDEuMiAyLjU2NyAyLjk1OEg1LjQzM0M1Ljk2MyAzLjIgNi45MjUgMiA4IDJ6bS0yLjU2LjU4MmE3LjcgNy43IDAgMCAwLTEuMDUxIDIuMzc2SDIuODM3QTYuMDMgNi4wMyAwIDAgMSA1LjQ0IDIuNTgyem0tMi42IDguNDZoMS41NDlhNy43IDcuNyAwIDAgMCAxLjA1MSAyLjM3NiA2LjAzIDYuMDMgMCAwIDEtMi42MDMtMi4zNzZ6bTcuNzIzIDIuMzc2YTcuNyA3LjcgMCAwIDAgMS4wNTEtMi4zNzZoMS41NTJhNi4wMyA2LjAzIDAgMCAxLTIuNjA2IDIuMzc2eiIvPgo8L3N2Zz4K"> +<iframe id="iframe" allow="fullscreen *" allowfullscreen></iframe> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_non_eligible_media.html b/dom/media/mediacontrol/tests/browser/file_non_eligible_media.html new file mode 100644 index 0000000000..bf27943fce --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_non_eligible_media.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> +<head> +<title>Media are not eligible to be controlled</title> +</head> +<body> +<video id="muted" src="gizmo.mp4" controls muted loop></video> +<video id="volume-0" src="gizmo.mp4" controls loop></video> +<video id="no-audio-track" src="gizmo-noaudio.webm" controls loop></video> +<video id="silent-audio-track" src="silentAudioTrack.webm" controls loop></video> +<video id="short-duration" src="gizmo-short.mp4" controls loop></video> +<video id="inaudible-captured-media" src="gizmo.mp4" muted controls loop></video> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/file_non_looping_media.html b/dom/media/mediacontrol/tests/browser/file_non_looping_media.html new file mode 100644 index 0000000000..41e049645c --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/file_non_looping_media.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> +<title>Non looping media page</title> +</head> +<body> +<video id="video" src="gizmo.mp4"></video> +</body> +</html> diff --git a/dom/media/mediacontrol/tests/browser/head.js b/dom/media/mediacontrol/tests/browser/head.js new file mode 100644 index 0000000000..cac96c0bff --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/head.js @@ -0,0 +1,402 @@ +/** + * This function would create a new foreround tab and load the url for it. In + * addition, instead of returning a tab element, we return a tab wrapper that + * helps us to automatically detect if the media controller of that tab + * dispatches the first (activated) and the last event (deactivated) correctly. + * @ param url + * the page url which tab would load + * @ param input window (optional) + * if it exists, the tab would be created from the input window. If not, + * then the tab would be created in current window. + * @ param needCheck (optional) + * it decides if we would perform the check for the first and last event + * on the media controller. It's default true. + */ +async function createLoadedTabWrapper( + url, + { inputWindow = window, needCheck = true } = {} +) { + class tabWrapper { + constructor(tab, needCheck) { + this._tab = tab; + this._controller = tab.linkedBrowser.browsingContext.mediaController; + this._firstEvent = ""; + this._lastEvent = ""; + this._events = [ + "activated", + "deactivated", + "metadatachange", + "playbackstatechange", + "positionstatechange", + "supportedkeyschange", + ]; + this._needCheck = needCheck; + if (this._needCheck) { + this._registerAllEvents(); + } + } + _registerAllEvents() { + for (let event of this._events) { + this._controller.addEventListener(event, this._handleEvent.bind(this)); + } + } + _unregisterAllEvents() { + for (let event of this._events) { + this._controller.removeEventListener( + event, + this._handleEvent.bind(this) + ); + } + } + _handleEvent(event) { + info(`handle event=${event.type}`); + if (this._firstEvent === "") { + this._firstEvent = event.type; + } + this._lastEvent = event.type; + } + get linkedBrowser() { + return this._tab.linkedBrowser; + } + get controller() { + return this._controller; + } + get tabElement() { + return this._tab; + } + async close() { + info(`wait until finishing close tab wrapper`); + const deactivationPromise = this._controller.isActive + ? new Promise(r => (this._controller.ondeactivated = r)) + : Promise.resolve(); + BrowserTestUtils.removeTab(this._tab); + await deactivationPromise; + if (this._needCheck) { + is(this._firstEvent, "activated", "First event should be 'activated'"); + is( + this._lastEvent, + "deactivated", + "Last event should be 'deactivated'" + ); + this._unregisterAllEvents(); + } + } + } + const browser = inputWindow ? inputWindow.gBrowser : window.gBrowser; + let tab = await BrowserTestUtils.openNewForegroundTab(browser, url); + return new tabWrapper(tab, needCheck); +} + +/** + * Returns a promise that resolves when generated media control keys has + * triggered the main media controller's corresponding method and changes its + * playback state. + * + * @param {string} event + * The event name of the media control key + * @return {Promise} + * Resolve when the main controller receives the media control key event + * and change its playback state. + */ +function generateMediaControlKeyEvent(event) { + const playbackStateChanged = waitUntilDisplayedPlaybackChanged(); + MediaControlService.generateMediaControlKey(event); + return playbackStateChanged; +} + +/** + * Play the specific media and wait until it plays successfully and the main + * controller has been updated. + * + * @param {tab} tab + * The tab that contains the media which we would play + * @param {string} elementId + * The element Id of the media which we would play + * @return {Promise} + * Resolve when the media has been starting playing and the main + * controller has been updated. + */ +async function playMedia(tab, elementId) { + const playbackStatePromise = waitUntilDisplayedPlaybackChanged(); + await SpecialPowers.spawn(tab.linkedBrowser, [elementId], async Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + ok( + await video.play().then( + _ => true, + _ => false + ), + "video started playing" + ); + }); + return playbackStatePromise; +} + +/** + * Pause the specific media and wait until it pauses successfully and the main + * controller has been updated. + * + * @param {tab} tab + * The tab that contains the media which we would pause + * @param {string} elementId + * The element Id of the media which we would pause + * @return {Promise} + * Resolve when the media has been paused and the main controller has + * been updated. + */ +function pauseMedia(tab, elementId) { + const pausePromise = SpecialPowers.spawn( + tab.linkedBrowser, + [elementId], + Id => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + ok(!video.paused, `video is playing before calling pause`); + video.pause(); + } + ); + return Promise.all([pausePromise, waitUntilDisplayedPlaybackChanged()]); +} + +/** + * Returns a promise that resolves when the specific media starts playing. + * + * @param {tab} tab + * The tab that contains the media which we would check + * @param {string} elementId + * The element Id of the media which we would check + * @return {Promise} + * Resolve when the media has been starting playing. + */ +function checkOrWaitUntilMediaStartedPlaying(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + return new Promise(resolve => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + if (!video.paused) { + ok(true, `media started playing`); + resolve(); + } else { + info(`wait until media starts playing`); + video.onplaying = () => { + video.onplaying = null; + ok(true, `media started playing`); + resolve(); + }; + } + }); + }); +} + +/** + * Returns a promise that resolves when the specific media stops playing. + * + * @param {tab} tab + * The tab that contains the media which we would check + * @param {string} elementId + * The element Id of the media which we would check + * @return {Promise} + * Resolve when the media has been stopped playing. + */ +function checkOrWaitUntilMediaStoppedPlaying(tab, elementId) { + return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => { + return new Promise(resolve => { + const video = content.document.getElementById(Id); + if (!video) { + ok(false, `can't get the media element!`); + } + if (video.paused) { + ok(true, `media stopped playing`); + resolve(); + } else { + info(`wait until media stops playing`); + video.onpause = () => { + video.onpause = null; + ok(true, `media stopped playing`); + resolve(); + }; + } + }); + }); +} + +/** + * Check if the active metadata is empty. + */ +function isCurrentMetadataEmpty() { + const current = MediaControlService.getCurrentActiveMediaMetadata(); + is(current.title, "", `current title should be empty`); + is(current.artist, "", `current title should be empty`); + is(current.album, "", `current album should be empty`); + is(current.artwork.length, 0, `current artwork should be empty`); +} + +/** + * Check if the active metadata is equal to the given metadata.artwork + * + * @param {object} metadata + * The metadata that would be compared with the active metadata + */ +function isCurrentMetadataEqualTo(metadata) { + const current = MediaControlService.getCurrentActiveMediaMetadata(); + is( + current.title, + metadata.title, + `tile '${current.title}' is equal to ${metadata.title}` + ); + is( + current.artist, + metadata.artist, + `artist '${current.artist}' is equal to ${metadata.artist}` + ); + is( + current.album, + metadata.album, + `album '${current.album}' is equal to ${metadata.album}` + ); + is( + current.artwork.length, + metadata.artwork.length, + `artwork length '${current.artwork.length}' is equal to ${metadata.artwork.length}` + ); + for (let idx = 0; idx < metadata.artwork.length; idx++) { + // the current src we got would be a completed path of the image, so we do + // not check if they are equal, we check if the current src includes the + // metadata's file name. Eg. "http://foo/bar.jpg" v.s. "bar.jpg" + ok( + current.artwork[idx].src.includes(metadata.artwork[idx].src), + `artwork src '${current.artwork[idx].src}' includes ${metadata.artwork[idx].src}` + ); + is( + current.artwork[idx].sizes, + metadata.artwork[idx].sizes, + `artwork sizes '${current.artwork[idx].sizes}' is equal to ${metadata.artwork[idx].sizes}` + ); + is( + current.artwork[idx].type, + metadata.artwork[idx].type, + `artwork type '${current.artwork[idx].type}' is equal to ${metadata.artwork[idx].type}` + ); + } +} + +/** + * Check if the given tab is using the default metadata. If the tab is being + * used in the private browsing mode, `isPrivateBrowsing` should be definded in + * the `options`. + */ +async function isGivenTabUsingDefaultMetadata(tab, options = {}) { + const localization = new Localization([ + "branding/brand.ftl", + "dom/media.ftl", + ]); + const fallbackTitle = await localization.formatValue( + "mediastatus-fallback-title" + ); + ok(fallbackTitle.length, "l10n fallback title is not empty"); + + const metadata = + tab.linkedBrowser.browsingContext.mediaController.getMetadata(); + + await SpecialPowers.spawn( + tab.linkedBrowser, + [metadata.title, fallbackTitle, options.isPrivateBrowsing], + (title, fallbackTitle, isPrivateBrowsing) => { + if (isPrivateBrowsing || !content.document.title.length) { + is(title, fallbackTitle, "Using a generic default fallback title"); + } else { + is( + title, + content.document.title, + "Using website title as a default title" + ); + } + } + ); + is(metadata.artwork.length, 1, "Default metada contains one artwork"); + ok( + metadata.artwork[0].src.includes("defaultFavicon.svg"), + "Using default favicon as a default art work" + ); +} + +/** + * Wait until the main media controller changes its playback state, we would + * observe that by listening for `media-displayed-playback-changed` + * notification. + * + * @return {Promise} + * Resolve when observing `media-displayed-playback-changed` + */ +function waitUntilDisplayedPlaybackChanged() { + return BrowserUtils.promiseObserved("media-displayed-playback-changed"); +} + +/** + * Wait until the metadata that would be displayed on the virtual control + * interface changes. we would observe that by listening for + * `media-displayed-metadata-changed` notification. + * + * @return {Promise} + * Resolve when observing `media-displayed-metadata-changed` + */ +function waitUntilDisplayedMetadataChanged() { + return BrowserUtils.promiseObserved("media-displayed-metadata-changed"); +} + +/** + * Wait until the main media controller has been changed, we would observe that + * by listening for the `main-media-controller-changed` notification. + * + * @return {Promise} + * Resolve when observing `main-media-controller-changed` + */ +function waitUntilMainMediaControllerChanged() { + return BrowserUtils.promiseObserved("main-media-controller-changed"); +} + +/** + * Wait until any media controller updates its metadata even if it's not the + * main controller. The difference between this function and + * `waitUntilDisplayedMetadataChanged()` is that the changed metadata might come + * from non-main controller so it won't be show on the virtual control + * interface. we would observe that by listening for + * `media-session-controller-metadata-changed` notification. + * + * @return {Promise} + * Resolve when observing `media-session-controller-metadata-changed` + */ +function waitUntilControllerMetadataChanged() { + return BrowserUtils.promiseObserved( + "media-session-controller-metadata-changed" + ); +} + +/** + * Wait until media controller amount changes, we would observe that by + * listening for `media-controller-amount-changed` notification. + * + * @return {Promise} + * Resolve when observing `media-controller-amount-changed` + */ +function waitUntilMediaControllerAmountChanged() { + return BrowserUtils.promiseObserved("media-controller-amount-changed"); +} + +/** + * check if the media controll from given tab is active. If not, return a + * promise and resolve it when controller become active. + */ +async function checkOrWaitUntilControllerBecomeActive(tab) { + const controller = tab.linkedBrowser.browsingContext.mediaController; + if (controller.isActive) { + return; + } + await new Promise(r => (controller.onactivated = r)); +} diff --git a/dom/media/mediacontrol/tests/gtest/MediaKeyListenerTest.h b/dom/media/mediacontrol/tests/gtest/MediaKeyListenerTest.h new file mode 100644 index 0000000000..5145eb7dbb --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/MediaKeyListenerTest.h @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef DOM_MEDIA_MEDIAKEYLISTENERTEST_H_ +#define DOM_MEDIA_MEDIAKEYLISTENERTEST_H_ + +#include "MediaControlKeySource.h" +#include "mozilla/Maybe.h" + +namespace mozilla { +namespace dom { + +class MediaKeyListenerTest : public MediaControlKeyListener { + public: + NS_INLINE_DECL_REFCOUNTING(MediaKeyListenerTest, override) + + void Clear() { mReceivedKey = mozilla::Nothing(); } + + void OnActionPerformed(const MediaControlAction& aAction) override { + mReceivedKey = mozilla::Some(aAction.mKey); + } + bool IsResultEqualTo(MediaControlKey aResult) const { + if (mReceivedKey) { + return *mReceivedKey == aResult; + } + return false; + } + bool IsReceivedResult() const { return mReceivedKey.isSome(); } + + private: + ~MediaKeyListenerTest() = default; + mozilla::Maybe<MediaControlKey> mReceivedKey; +}; + +} // namespace dom +} // namespace mozilla + +#endif // DOM_MEDIA_MEDIAKEYLISTENERTEST_H_ diff --git a/dom/media/mediacontrol/tests/gtest/TestAudioFocusManager.cpp b/dom/media/mediacontrol/tests/gtest/TestAudioFocusManager.cpp new file mode 100644 index 0000000000..df1bb288a4 --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/TestAudioFocusManager.cpp @@ -0,0 +1,163 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "gtest/gtest.h" +#include "AudioFocusManager.h" +#include "MediaControlService.h" +#include "mozilla/Preferences.h" + +using namespace mozilla::dom; + +#define FIRST_CONTROLLER_ID 0 +#define SECOND_CONTROLLER_ID 1 + +// This RAII class is used to set the audio focus management pref within a test +// and automatically revert the change when a test ends, in order not to +// interfere other tests unexpectedly. +class AudioFocusManagmentPrefSetterRAII { + public: + explicit AudioFocusManagmentPrefSetterRAII(bool aPrefValue) { + mOriginalValue = mozilla::Preferences::GetBool(mPrefName, false); + mozilla::Preferences::SetBool(mPrefName, aPrefValue); + } + ~AudioFocusManagmentPrefSetterRAII() { + mozilla::Preferences::SetBool(mPrefName, mOriginalValue); + } + + private: + const char* mPrefName = "media.audioFocus.management"; + bool mOriginalValue; +}; + +TEST(AudioFocusManager, TestRequestAudioFocus) +{ + AudioFocusManager manager; + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); + + RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID); + + manager.RequestAudioFocus(controller); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); + + manager.RevokeAudioFocus(controller); + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); +} + +TEST(AudioFocusManager, TestAudioFocusNumsWhenEnableAudioFocusManagement) +{ + // When enabling audio focus management, we only allow one controller owing + // audio focus at a time when the audio competing occurs. As the mechanism of + // handling the audio competing involves multiple components, we can't test it + // simply by using the APIs from AudioFocusManager. + AudioFocusManagmentPrefSetterRAII prefSetter(true); + + AudioFocusManager manager; + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); + + RefPtr<MediaController> controller1 = + new MediaController(FIRST_CONTROLLER_ID); + + RefPtr<MediaController> controller2 = + new MediaController(SECOND_CONTROLLER_ID); + + manager.RequestAudioFocus(controller1); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); + + // When controller2 starts, it would win the audio focus from controller1. So + // only one audio focus would exist. + manager.RequestAudioFocus(controller2); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); + + manager.RevokeAudioFocus(controller2); + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); +} + +TEST(AudioFocusManager, TestAudioFocusNumsWhenDisableAudioFocusManagement) +{ + // When disabling audio focus management, we won't handle the audio competing, + // so we allow multiple audio focus existing at the same time. + AudioFocusManagmentPrefSetterRAII prefSetter(false); + + AudioFocusManager manager; + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); + + RefPtr<MediaController> controller1 = + new MediaController(FIRST_CONTROLLER_ID); + + RefPtr<MediaController> controller2 = + new MediaController(SECOND_CONTROLLER_ID); + + manager.RequestAudioFocus(controller1); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); + + manager.RequestAudioFocus(controller2); + ASSERT_TRUE(manager.GetAudioFocusNums() == 2); + + manager.RevokeAudioFocus(controller1); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); + + manager.RevokeAudioFocus(controller2); + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); +} + +TEST(AudioFocusManager, TestRequestAudioFocusRepeatedly) +{ + AudioFocusManager manager; + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); + + RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID); + + manager.RequestAudioFocus(controller); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); + + manager.RequestAudioFocus(controller); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); +} + +TEST(AudioFocusManager, TestRevokeAudioFocusRepeatedly) +{ + AudioFocusManager manager; + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); + + RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID); + + manager.RequestAudioFocus(controller); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); + + manager.RevokeAudioFocus(controller); + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); + + manager.RevokeAudioFocus(controller); + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); +} + +TEST(AudioFocusManager, TestRevokeAudioFocusWithoutRequestAudioFocus) +{ + AudioFocusManager manager; + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); + + RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID); + + manager.RevokeAudioFocus(controller); + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); +} + +TEST(AudioFocusManager, + TestRevokeAudioFocusForControllerWithoutOwningAudioFocus) +{ + AudioFocusManager manager; + ASSERT_TRUE(manager.GetAudioFocusNums() == 0); + + RefPtr<MediaController> controller1 = + new MediaController(FIRST_CONTROLLER_ID); + + RefPtr<MediaController> controller2 = + new MediaController(SECOND_CONTROLLER_ID); + + manager.RequestAudioFocus(controller1); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); + + manager.RevokeAudioFocus(controller2); + ASSERT_TRUE(manager.GetAudioFocusNums() == 1); +} diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaControlService.cpp b/dom/media/mediacontrol/tests/gtest/TestMediaControlService.cpp new file mode 100644 index 0000000000..ab5b0cb8c7 --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/TestMediaControlService.cpp @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "gtest/gtest.h" +#include "MediaControlService.h" +#include "MediaController.h" + +using namespace mozilla::dom; + +#define FIRST_CONTROLLER_ID 0 +#define SECOND_CONTROLLER_ID 1 + +TEST(MediaControlService, TestAddOrRemoveControllers) +{ + RefPtr<MediaControlService> service = MediaControlService::GetService(); + ASSERT_TRUE(service->GetActiveControllersNum() == 0); + + RefPtr<MediaController> controller1 = + new MediaController(FIRST_CONTROLLER_ID); + RefPtr<MediaController> controller2 = + new MediaController(SECOND_CONTROLLER_ID); + + service->RegisterActiveMediaController(controller1); + ASSERT_TRUE(service->GetActiveControllersNum() == 1); + + service->RegisterActiveMediaController(controller2); + ASSERT_TRUE(service->GetActiveControllersNum() == 2); + + service->UnregisterActiveMediaController(controller1); + ASSERT_TRUE(service->GetActiveControllersNum() == 1); + + service->UnregisterActiveMediaController(controller2); + ASSERT_TRUE(service->GetActiveControllersNum() == 0); +} + +TEST(MediaControlService, TestMainController) +{ + RefPtr<MediaControlService> service = MediaControlService::GetService(); + ASSERT_TRUE(service->GetActiveControllersNum() == 0); + + RefPtr<MediaController> controller1 = + new MediaController(FIRST_CONTROLLER_ID); + service->RegisterActiveMediaController(controller1); + + RefPtr<MediaController> mainController = service->GetMainController(); + ASSERT_TRUE(mainController->Id() == FIRST_CONTROLLER_ID); + + RefPtr<MediaController> controller2 = + new MediaController(SECOND_CONTROLLER_ID); + service->RegisterActiveMediaController(controller2); + + mainController = service->GetMainController(); + ASSERT_TRUE(mainController->Id() == SECOND_CONTROLLER_ID); + + service->UnregisterActiveMediaController(controller2); + mainController = service->GetMainController(); + ASSERT_TRUE(mainController->Id() == FIRST_CONTROLLER_ID); + + service->UnregisterActiveMediaController(controller1); + mainController = service->GetMainController(); + ASSERT_TRUE(service->GetActiveControllersNum() == 0); + ASSERT_TRUE(!mainController); +} diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaController.cpp b/dom/media/mediacontrol/tests/gtest/TestMediaController.cpp new file mode 100644 index 0000000000..5ba8d2a7d5 --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/TestMediaController.cpp @@ -0,0 +1,204 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "gtest/gtest.h" +#include "MediaControlService.h" +#include "MediaController.h" +#include "mozilla/dom/MediaSessionBinding.h" + +using namespace mozilla::dom; + +#define CONTROLLER_ID 0 +#define FAKE_CONTEXT_ID 0 + +#define FIRST_CONTROLLER_ID 0 + +TEST(MediaController, DefaultValueCheck) +{ + RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID); + ASSERT_TRUE(!controller->IsAnyMediaBeingControlled()); + ASSERT_TRUE(controller->Id() == CONTROLLER_ID); + ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::None); + ASSERT_TRUE(!controller->IsAudible()); +} + +TEST(MediaController, IsAnyMediaBeingControlled) +{ + RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID); + ASSERT_TRUE(!controller->IsAnyMediaBeingControlled()); + + controller->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID, + MediaPlaybackState::eStarted); + ASSERT_TRUE(controller->IsAnyMediaBeingControlled()); + + controller->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID, + MediaPlaybackState::eStarted); + ASSERT_TRUE(controller->IsAnyMediaBeingControlled()); + + controller->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID, + MediaPlaybackState::eStopped); + ASSERT_TRUE(controller->IsAnyMediaBeingControlled()); + + controller->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID, + MediaPlaybackState::eStopped); + ASSERT_TRUE(!controller->IsAnyMediaBeingControlled()); +} + +class FakeControlledMedia final { + public: + explicit FakeControlledMedia(MediaController* aController) + : mController(aController) { + mController->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID, + MediaPlaybackState::eStarted); + } + + void SetPlaying(MediaPlaybackState aState) { + if (mPlaybackState == aState) { + return; + } + mController->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID, aState); + mPlaybackState = aState; + } + + void SetAudible(MediaAudibleState aState) { + if (mAudibleState == aState) { + return; + } + mController->NotifyMediaAudibleChanged(FAKE_CONTEXT_ID, aState); + mAudibleState = aState; + } + + ~FakeControlledMedia() { + if (mPlaybackState == MediaPlaybackState::ePlayed) { + mController->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID, + MediaPlaybackState::ePaused); + } + mController->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID, + MediaPlaybackState::eStopped); + } + + private: + MediaPlaybackState mPlaybackState = MediaPlaybackState::eStopped; + MediaAudibleState mAudibleState = MediaAudibleState::eInaudible; + RefPtr<MediaController> mController; +}; + +TEST(MediaController, ActiveAndDeactiveController) +{ + RefPtr<MediaControlService> service = MediaControlService::GetService(); + ASSERT_TRUE(service->GetActiveControllersNum() == 0); + + RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID); + + // In order to check active control number after FakeControlledMedia + // destroyed. + { + FakeControlledMedia fakeMedia(controller); + fakeMedia.SetPlaying(MediaPlaybackState::ePlayed); + ASSERT_TRUE(service->GetActiveControllersNum() == 1); + + fakeMedia.SetAudible(MediaAudibleState::eAudible); + ASSERT_TRUE(service->GetActiveControllersNum() == 1); + + fakeMedia.SetAudible(MediaAudibleState::eInaudible); + ASSERT_TRUE(service->GetActiveControllersNum() == 1); + } + + ASSERT_TRUE(service->GetActiveControllersNum() == 0); +} + +TEST(MediaController, AudibleChanged) +{ + RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID); + + FakeControlledMedia fakeMedia(controller); + fakeMedia.SetPlaying(MediaPlaybackState::ePlayed); + ASSERT_TRUE(!controller->IsAudible()); + + fakeMedia.SetAudible(MediaAudibleState::eAudible); + ASSERT_TRUE(controller->IsAudible()); + + fakeMedia.SetAudible(MediaAudibleState::eInaudible); + ASSERT_TRUE(!controller->IsAudible()); +} + +TEST(MediaController, PlayingStateChangeViaControlledMedia) +{ + RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID); + + // In order to check playing state after FakeControlledMedia destroyed. + { + FakeControlledMedia foo(controller); + ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::None); + + foo.SetPlaying(MediaPlaybackState::ePlayed); + ASSERT_TRUE(controller->PlaybackState() == + MediaSessionPlaybackState::Playing); + + foo.SetPlaying(MediaPlaybackState::ePaused); + ASSERT_TRUE(controller->PlaybackState() == + MediaSessionPlaybackState::Paused); + + foo.SetPlaying(MediaPlaybackState::ePlayed); + ASSERT_TRUE(controller->PlaybackState() == + MediaSessionPlaybackState::Playing); + } + + // FakeControlledMedia has been destroyed, no playing media exists. + ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::Paused); +} + +TEST(MediaController, ControllerShouldRemainPlayingIfAnyPlayingMediaExists) +{ + RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID); + + { + FakeControlledMedia foo(controller); + ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::None); + + foo.SetPlaying(MediaPlaybackState::ePlayed); + ASSERT_TRUE(controller->PlaybackState() == + MediaSessionPlaybackState::Playing); + + // foo is playing, so controller is in `playing` state. + FakeControlledMedia bar(controller); + ASSERT_TRUE(controller->PlaybackState() == + MediaSessionPlaybackState::Playing); + + bar.SetPlaying(MediaPlaybackState::ePlayed); + ASSERT_TRUE(controller->PlaybackState() == + MediaSessionPlaybackState::Playing); + + // Although we paused bar, but foo is still playing, so the controller would + // still be in `playing`. + bar.SetPlaying(MediaPlaybackState::ePaused); + ASSERT_TRUE(controller->PlaybackState() == + MediaSessionPlaybackState::Playing); + + foo.SetPlaying(MediaPlaybackState::ePaused); + ASSERT_TRUE(controller->PlaybackState() == + MediaSessionPlaybackState::Paused); + } + + // both foo and bar have been destroyed, no playing media exists. + ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::Paused); +} + +TEST(MediaController, PictureInPictureModeOrFullscreen) +{ + RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID); + ASSERT_TRUE(!controller->IsBeingUsedInPIPModeOrFullscreen()); + + controller->SetIsInPictureInPictureMode(FAKE_CONTEXT_ID, true); + ASSERT_TRUE(controller->IsBeingUsedInPIPModeOrFullscreen()); + + controller->SetIsInPictureInPictureMode(FAKE_CONTEXT_ID, false); + ASSERT_TRUE(!controller->IsBeingUsedInPIPModeOrFullscreen()); + + controller->NotifyMediaFullScreenState(FAKE_CONTEXT_ID, true); + ASSERT_TRUE(controller->IsBeingUsedInPIPModeOrFullscreen()); + + controller->NotifyMediaFullScreenState(FAKE_CONTEXT_ID, false); + ASSERT_TRUE(!controller->IsBeingUsedInPIPModeOrFullscreen()); +} diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaKeysEvent.cpp b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEvent.cpp new file mode 100644 index 0000000000..a73ceb8592 --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEvent.cpp @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "gtest/gtest.h" +#include "MediaController.h" +#include "MediaControlKeySource.h" + +using namespace mozilla::dom; + +class MediaControlKeySourceTestImpl : public MediaControlKeySource { + public: + NS_INLINE_DECL_REFCOUNTING(MediaControlKeySourceTestImpl, override) + bool Open() override { return true; } + bool IsOpened() const override { return true; } + void SetSupportedMediaKeys(const MediaKeysArray& aSupportedKeys) override {} + + private: + ~MediaControlKeySourceTestImpl() = default; +}; + +TEST(MediaControlKey, TestAddOrRemoveListener) +{ + RefPtr<MediaControlKeySource> source = new MediaControlKeySourceTestImpl(); + ASSERT_TRUE(source->GetListenersNum() == 0); + + RefPtr<MediaControlKeyListener> listener = new MediaControlKeyHandler(); + + source->AddListener(listener); + ASSERT_TRUE(source->GetListenersNum() == 1); + + source->RemoveListener(listener); + ASSERT_TRUE(source->GetListenersNum() == 0); +} + +TEST(MediaControlKey, SetSourcePlaybackState) +{ + RefPtr<MediaControlKeySource> source = new MediaControlKeySourceTestImpl(); + ASSERT_TRUE(source->GetPlaybackState() == MediaSessionPlaybackState::None); + + source->SetPlaybackState(MediaSessionPlaybackState::Playing); + ASSERT_TRUE(source->GetPlaybackState() == MediaSessionPlaybackState::Playing); + + source->SetPlaybackState(MediaSessionPlaybackState::Paused); + ASSERT_TRUE(source->GetPlaybackState() == MediaSessionPlaybackState::Paused); + + source->SetPlaybackState(MediaSessionPlaybackState::None); + ASSERT_TRUE(source->GetPlaybackState() == MediaSessionPlaybackState::None); +} diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMac.mm b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMac.mm new file mode 100644 index 0000000000..3c8ce8cd9e --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMac.mm @@ -0,0 +1,136 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#import <AppKit/AppKit.h> +#import <AppKit/NSEvent.h> +#import <ApplicationServices/ApplicationServices.h> +#import <CoreFoundation/CoreFoundation.h> +#import <IOKit/hidsystem/ev_keymap.h> + +#include "gtest/gtest.h" +#include "MediaHardwareKeysEventSourceMac.h" +#include "MediaKeyListenerTest.h" +#include "mozilla/Maybe.h" + +using namespace mozilla::dom; +using namespace mozilla::widget; + +static const int kSystemDefinedEventMediaKeysSubtype = 8; + +static void SendFakeEvent(RefPtr<MediaHardwareKeysEventSourceMac>& aSource, int aKeyData) { + NSEvent* event = [NSEvent otherEventWithType:NSEventTypeSystemDefined + location:NSZeroPoint + modifierFlags:0 + timestamp:0 + windowNumber:0 + context:nil + subtype:kSystemDefinedEventMediaKeysSubtype + data1:aKeyData + data2:0]; + aSource->EventTapCallback(nullptr, static_cast<CGEventType>(0), [event CGEvent], aSource.get()); +} + +static void NotifyFakeNonMediaKey(RefPtr<MediaHardwareKeysEventSourceMac>& aSource, + bool aIsKeyPressed) { + int keyData = 0 | ((aIsKeyPressed ? 0xA : 0xB) << 8); + SendFakeEvent(aSource, keyData); +} + +static void NotifyFakeMediaControlKey(RefPtr<MediaHardwareKeysEventSourceMac>& aSource, + MediaControlKey aEvent, bool aIsKeyPressed) { + int keyData = 0; + if (aEvent == MediaControlKey::Playpause) { + keyData = NX_KEYTYPE_PLAY << 16; + } else if (aEvent == MediaControlKey::Nexttrack) { + keyData = NX_KEYTYPE_NEXT << 16; + } else if (aEvent == MediaControlKey::Previoustrack) { + keyData = NX_KEYTYPE_PREVIOUS << 16; + } + keyData |= ((aIsKeyPressed ? 0xA : 0xB) << 8); + SendFakeEvent(aSource, keyData); +} + +static void NotifyKeyPressedMediaKey(RefPtr<MediaHardwareKeysEventSourceMac>& aSource, + MediaControlKey aEvent) { + NotifyFakeMediaControlKey(aSource, aEvent, true /* key pressed */); +} + +static void NotifyKeyReleasedMediaKeysEvent(RefPtr<MediaHardwareKeysEventSourceMac>& aSource, + MediaControlKey aEvent) { + NotifyFakeMediaControlKey(aSource, aEvent, false /* key released */); +} + +static void NotifyKeyPressedNonMediaKeysEvents(RefPtr<MediaHardwareKeysEventSourceMac>& aSource) { + NotifyFakeNonMediaKey(aSource, true /* key pressed */); +} + +static void NotifyKeyReleasedNonMediaKeysEvents(RefPtr<MediaHardwareKeysEventSourceMac>& aSource) { + NotifyFakeNonMediaKey(aSource, false /* key released */); +} + +TEST(MediaHardwareKeysEventSourceMac, TestKeyPressedMediaKeysEvent) +{ + RefPtr<MediaHardwareKeysEventSourceMac> source = new MediaHardwareKeysEventSourceMac(); + ASSERT_TRUE(source->GetListenersNum() == 0); + + RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest(); + source->AddListener(listener.get()); + ASSERT_TRUE(source->GetListenersNum() == 1); + ASSERT_TRUE(!listener->IsReceivedResult()); + + NotifyKeyPressedMediaKey(source, MediaControlKey::Playpause); + ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Playpause)); + + NotifyKeyPressedMediaKey(source, MediaControlKey::Nexttrack); + ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Nexttrack)); + + NotifyKeyPressedMediaKey(source, MediaControlKey::Previoustrack); + ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Previoustrack)); + + source->RemoveListener(listener); + ASSERT_TRUE(source->GetListenersNum() == 0); +} + +TEST(MediaHardwareKeysEventSourceMac, TestKeyReleasedMediaKeysEvent) +{ + RefPtr<MediaHardwareKeysEventSourceMac> source = new MediaHardwareKeysEventSourceMac(); + ASSERT_TRUE(source->GetListenersNum() == 0); + + RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest(); + source->AddListener(listener.get()); + ASSERT_TRUE(source->GetListenersNum() == 1); + ASSERT_TRUE(!listener->IsReceivedResult()); + + NotifyKeyReleasedMediaKeysEvent(source, MediaControlKey::Playpause); + ASSERT_TRUE(!listener->IsReceivedResult()); + + NotifyKeyReleasedMediaKeysEvent(source, MediaControlKey::Nexttrack); + ASSERT_TRUE(!listener->IsReceivedResult()); + + NotifyKeyReleasedMediaKeysEvent(source, MediaControlKey::Previoustrack); + ASSERT_TRUE(!listener->IsReceivedResult()); + + source->RemoveListener(listener); + ASSERT_TRUE(source->GetListenersNum() == 0); +} + +TEST(MediaHardwareKeysEventSourceMac, TestNonMediaKeysEvent) +{ + RefPtr<MediaHardwareKeysEventSourceMac> source = new MediaHardwareKeysEventSourceMac(); + ASSERT_TRUE(source->GetListenersNum() == 0); + + RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest(); + source->AddListener(listener.get()); + ASSERT_TRUE(source->GetListenersNum() == 1); + ASSERT_TRUE(!listener->IsReceivedResult()); + + NotifyKeyPressedNonMediaKeysEvents(source); + ASSERT_TRUE(!listener->IsReceivedResult()); + + NotifyKeyReleasedNonMediaKeysEvents(source); + ASSERT_TRUE(!listener->IsReceivedResult()); + + source->RemoveListener(listener); + ASSERT_TRUE(source->GetListenersNum() == 0); +} diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMediaCenter.mm b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMediaCenter.mm new file mode 100644 index 0000000000..1d32960d2f --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMediaCenter.mm @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +#import <MediaPlayer/MediaPlayer.h> + +#include "gtest/gtest.h" +#include "MediaHardwareKeysEventSourceMacMediaCenter.h" +#include "MediaKeyListenerTest.h" +#include "nsCocoaFeatures.h" +#include "nsCocoaUtils.h" +#include "prinrval.h" +#include "prthread.h" + +using namespace mozilla::dom; +using namespace mozilla::widget; + +NS_ASSUME_NONNULL_BEGIN + +TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestMediaCenterPlayPauseEvent) +{ + RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source = + new MediaHardwareKeysEventSourceMacMediaCenter(); + + ASSERT_TRUE(source->GetListenersNum() == 0); + + RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest(); + + MPNowPlayingInfoCenter* center = [MPNowPlayingInfoCenter defaultCenter]; + + source->AddListener(listener.get()); + + ASSERT_TRUE(source->Open()); + + ASSERT_TRUE(source->GetListenersNum() == 1); + ASSERT_TRUE(!listener->IsReceivedResult()); + ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying); + + MediaCenterEventHandler playPauseHandler = source->CreatePlayPauseHandler(); + playPauseHandler(nil); + + ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePaused); + ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Playpause)); + + listener->Clear(); // Reset stored media key + + playPauseHandler(nil); + + ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying); + ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Playpause)); +} + +TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestMediaCenterPlayEvent) +{ + RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source = + new MediaHardwareKeysEventSourceMacMediaCenter(); + + ASSERT_TRUE(source->GetListenersNum() == 0); + + RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest(); + + MPNowPlayingInfoCenter* center = [MPNowPlayingInfoCenter defaultCenter]; + + source->AddListener(listener.get()); + + ASSERT_TRUE(source->Open()); + + ASSERT_TRUE(source->GetListenersNum() == 1); + ASSERT_TRUE(!listener->IsReceivedResult()); + ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying); + + MediaCenterEventHandler playHandler = source->CreatePlayHandler(); + + center.playbackState = MPNowPlayingPlaybackStatePaused; + + playHandler(nil); + + ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying); + ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Play)); +} + +TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestMediaCenterPauseEvent) +{ + RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source = + new MediaHardwareKeysEventSourceMacMediaCenter(); + + ASSERT_TRUE(source->GetListenersNum() == 0); + + RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest(); + + MPNowPlayingInfoCenter* center = [MPNowPlayingInfoCenter defaultCenter]; + + source->AddListener(listener.get()); + + ASSERT_TRUE(source->Open()); + + ASSERT_TRUE(source->GetListenersNum() == 1); + ASSERT_TRUE(!listener->IsReceivedResult()); + ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying); + + MediaCenterEventHandler pauseHandler = source->CreatePauseHandler(); + + pauseHandler(nil); + + ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePaused); + ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Pause)); +} + +TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestMediaCenterPrevNextEvent) +{ + RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source = + new MediaHardwareKeysEventSourceMacMediaCenter(); + + ASSERT_TRUE(source->GetListenersNum() == 0); + + RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest(); + + source->AddListener(listener.get()); + + ASSERT_TRUE(source->Open()); + + MediaCenterEventHandler nextHandler = source->CreateNextTrackHandler(); + + nextHandler(nil); + + ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Nexttrack)); + + MediaCenterEventHandler previousHandler = source->CreatePreviousTrackHandler(); + + previousHandler(nil); + + ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Previoustrack)); +} + +TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestSetMetadata) +{ + RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source = + new MediaHardwareKeysEventSourceMacMediaCenter(); + + ASSERT_TRUE(source->GetListenersNum() == 0); + + RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest(); + + source->AddListener(listener.get()); + + ASSERT_TRUE(source->Open()); + + MediaMetadataBase metadata; + metadata.mTitle = u"MediaPlayback"; + metadata.mArtist = u"Firefox"; + metadata.mAlbum = u"Mozilla"; + source->SetMediaMetadata(metadata); + + // The update procedure of nowPlayingInfo is async, so wait for a second + // before checking the result. + PR_Sleep(PR_SecondsToInterval(1)); + MPNowPlayingInfoCenter* center = [MPNowPlayingInfoCenter defaultCenter]; + ASSERT_TRUE([center.nowPlayingInfo[MPMediaItemPropertyTitle] isEqualToString:@"MediaPlayback"]); + ASSERT_TRUE([center.nowPlayingInfo[MPMediaItemPropertyArtist] isEqualToString:@"Firefox"]); + ASSERT_TRUE([center.nowPlayingInfo[MPMediaItemPropertyAlbumTitle] isEqualToString:@"Mozilla"]); + + source->Close(); + PR_Sleep(PR_SecondsToInterval(1)); + ASSERT_TRUE(center.nowPlayingInfo == nil); +} + +NS_ASSUME_NONNULL_END diff --git a/dom/media/mediacontrol/tests/gtest/moz.build b/dom/media/mediacontrol/tests/gtest/moz.build new file mode 100644 index 0000000000..7043bfcd5e --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/moz.build @@ -0,0 +1,23 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +UNIFIED_SOURCES += [ + "TestAudioFocusManager.cpp", + "TestMediaController.cpp", + "TestMediaControlService.cpp", + "TestMediaKeysEvent.cpp", +] + +if CONFIG["MOZ_APPLEMEDIA"]: + UNIFIED_SOURCES += ["TestMediaKeysEventMac.mm", "TestMediaKeysEventMediaCenter.mm"] + +include("/ipc/chromium/chromium-config.mozbuild") + +LOCAL_INCLUDES += [ + "/dom/media/mediacontrol", +] + +FINAL_LIBRARY = "xul-gtest" |