import DomSmith from '../../lib/dom/DomSmith.js';
/**
* The FullScreen component manages entering and exiting fullscreen mode within the player.
* It supports the standardized Fullscreen API, as well as iOS-specific handling.
* A button in the controller area or the settings menu allows the user to toggle fullscreen.
* @exports module:src/controller/FullScreen
* @requires lib/dom/DomSmith
* @author Frank Kudermann - alphanull
* @version 1.1.0
* @license MIT
*/
export default class FullScreen {
/**
* Holds the instance configuration for this component.
* @type {Object}
* @property {string} [orientationLock='none'] Controls screen orientation behaviour while in fullscreen. Allowed values: 'none', 'auto', 'landscape', 'portrait'.
*/
#config = {
orientationLock: 'auto'
};
/**
* Reference to the media player instance.
* @type {module:src/core/Player}
*/
#player;
/**
* Array of subscription callbacks for player events.
* @type {number[]}
*/
#subscriptions;
/**
* Secret key only known to the player instance and initialized components.
* Used to be able to restrict access to API methods in conjunction with secure mode.
* @type {symbol}
*/
#apiKey;
/**
* The fullscreen button icon, created by DomSmith.
* @type {module:lib/dom/DomSmith}
*/
#dom;
/**
* This object delivers an abstract interface to the browsers fullscreen API by mapping the standard method, event and property names to the ones the current browser actually understands.
* It is necessary to use such an abstraction, because some older browsers use special vendor prefixed names.
* @type {module:src/controller/FullScreen~fsApiNames}
*/
#fsApi;
/**
* Flag indicating whether the player is in fullscreen mode.
* @type {boolean}
*/
#isFullScreen = false;
/**
* Flag indicating whether the player is currently playing (used for certain iOS handling).
* @type {boolean}
*/
#isPlaying = false;
/**
* Timer reference for delayed checks on iOS play/pause states.
* @type {number}
*/
#isPlayingDelay = -1;
/**
* Stores the previous orientation before applying a lock.
* @type {string|undefined}
*/
#previousOrientation;
/**
* Tracks whether an orientation lock was applied successfully.
* @type {boolean}
*/
#orientationLocked = false;
/**
* Creates an instance of the FullScreen component.
* @param {module:src/core/Player} player Reference to the VisionPlayer instance.
* @param {module:src/controller/Controller} parent Reference to the parent instance.
* @param {Object} [options] Additional options.
* @param {symbol} [options.apiKey] Token for extended access to the player API.
*/
constructor(player, parent, { apiKey }) {
this.#fsApi = this.#initFullScreenApi();
if (!player.getClient('iOS') && !this.#fsApi) return [false];
this.#config = player.initConfig('fullScreen', this.#config);
if (!this.#config) return [false];
this.#player = player;
this.#apiKey = apiKey;
this.#dom = new DomSmith({
_tag: 'button',
_ref: 'fsButton',
className: 'fullscreen-enter icon',
ariaLabel: this.#player.locale.t('misc.fullscreen'),
click: this.#toggleFullScreen,
'data-sort': 66,
$tooltip: { player, text: this.#player.locale.t('misc.fullscreen') }
}, parent.getElement('right'));
this.#player.setApi('fullscreen.enter', this.#launchFullScreen.bind(this), this.#apiKey);
this.#player.setApi('fullscreen.leave', this.#cancelFullScreen.bind(this), this.#apiKey);
this.#player.setState('ui.fullscreen', { get: () => this.#isFullScreen }, this.#apiKey);
const subs = [
['data/ready', this.#onDataReady]
];
// iOS-specific fullscreen toggles
if (this.#player.getClient('iOS') && !this.#fsApi) {
subs.push(
['media/play', this.#togglePlayPause],
['media/pause', this.#togglePlayPause],
['media/webkitbeginfullscreen', this.#enterFullScreen],
['media/webkitendfullscreen', this.#exitFullScreen]
);
} else {
document.addEventListener(this.#fsApi.fullscreenchange, this.#onFullScreen);
}
this.#subscriptions = subs.map(([event, handler]) => this.#player.subscribe(event, handler));
}
/**
* Initializes the Fullscreen API based on the browser's support.
* @returns {module:src/controller/FullScreen~fsApiNames|false} Returns name map, or 'false' if no matches were found.
*/
#initFullScreenApi() { // eslint-disable-line
const map = [
['requestFullscreen', 'exitFullscreen', 'fullscreenElement', 'fullscreenEnabled', 'fullscreenchange', 'fullscreenerror'], // Standard
['webkitRequestFullscreen', 'webkitExitFullscreen', 'webkitFullscreenElement', 'webkitFullscreenEnabled', 'webkitfullscreenchange', 'webkitfullscreenerror'] // new WebKit
];
const api = map.find(value => value && value[1] in document);
return api
? api.reduce((acc, val, index) => {
acc[map[0][index]] = val; return acc;
}, {})
: false;
}
/**
* Sets up the component once the media data is available.
* Disables fullscreen for iOS audio, otherwise enables the fullscreen button.
* @param {module:src/core/Data~mediaItem} mediaItem Object containing media type info.
* @param {string} mediaItem.mediaType Type of the media ('video' or 'audio').
* @listens module:src/core/Data#data/ready
*/
#onDataReady = ({ mediaType }) => {
if (this.#player.getClient('iOS') && mediaType === 'audio') {
this.#dom.fsButton.disabled = true; // on iOS, disable fullscreen button for audio
} else {
this.#dom.fsButton.disabled = false;
}
};
/**
* Toggles the internal `isPlaying` flag based on play/pause events.
* On iOS, used to track whether the player was playing when fullscreen ended.
* @param {null} event No Payload.
* @param {Event} topic The event topic ('media/play' or 'media/pause').
* @listens module:src/core/Media#media/play
* @listens module:src/core/Media#media/pause
*/
#togglePlayPause = (event, topic) => {
if (!this.#isFullScreen) return;
clearTimeout(this.#isPlayingDelay);
if (topic?.endsWith('media/pause') && this.#isPlayingDelay < 0) {
this.#isPlayingDelay = setTimeout(() => {
this.#isPlaying = false;
this.#isPlayingDelay = -1;
}, 500);
} else {
this.#isPlayingDelay = -1;
this.#isPlaying = true;
}
};
/**
* Toggles fullscreen mode on or off.
*/
#toggleFullScreen = () => {
if (document[this.#fsApi.fullscreenElement]) {
this.#cancelFullScreen();
} else {
this.#launchFullScreen();
}
};
/**
* Launches fullscreen mode using the fullscreen API or iOS-specific method.
* @param {HTMLElement} [element] The element to enter fullscreen (defaults to player root if not provided).
* @throws {Error} If fullscreen cannot be initiated.
*/
#launchFullScreen(element = this.#player.dom.getElement(this.#apiKey)) {
const request = this.#fsApi.requestFullscreen;
if (this.#player.getClient('iOS') && !this.#fsApi) {
this.#isPlaying = !this.#player.getState('media.paused');
this.#player.media.getElement(this.#apiKey).webkitEnterFullscreen();
} else {
element[request]();
}
}
/**
* Handler for native fullscreen events.
* @type {Function}
*/
#onFullScreen = () => {
if (document[this.#fsApi.fullscreenElement]) {
this.#enterFullScreen();
} else {
this.#exitFullScreen();
}
};
/**
* Cancels fullscreen mode.
* @throws {Error} If fullscreen cannot be exited.
*/
#cancelFullScreen() {
if (this.#player.getClient('iOS') && !this.#fsApi) {
this.#player.media.getElement(this.#apiKey).webkitExitFullscreen();
} else if (this.#isFullScreen) {
this.#restoreOrientation(true);
document[this.#fsApi.exitFullscreen]();
}
}
/**
* Called when fullscreen mode is launched.
* @fires module:src/controller/FullScreen#fullscreen/enter
* @listens module:src/core/Media#media/webkitbeginfullscreen
*/
#enterFullScreen = () => {
this.#dom.fsButton.classList.remove('fullscreen-enter');
this.#dom.fsButton.classList.add('fullscreen-exit');
this.#player.dom.getElement(this.#apiKey).classList.add('is-fullscreen');
this.#isFullScreen = true;
this.#lockOrientation();
this.#player.publish('fullscreen/enter', this.#apiKey);
};
/**
* Called when fullscreen mode is cancelled or exited.
* Handles iOS quirks regarding playback resumption.
* @fires module:src/controller/FullScreen#fullscreen/leave
* @listens module:src/core/Media#media/webkitendfullscreen
*/
#exitFullScreen = () => {
if (this.#player.getClient('iOS')) {
clearTimeout(this.#isPlayingDelay);
if (this.#isPlaying) {
this.#isPlayingDelay = setTimeout(() => this.#player.media.play(), 1000);
}
}
this.#dom.fsButton.classList.add('fullscreen-enter');
this.#dom.fsButton.classList.remove('fullscreen-exit');
this.#player.dom.getElement(this.#apiKey).classList.remove('is-fullscreen');
this.#isFullScreen = false;
this.#restoreOrientation();
this.#player.publish('fullscreen/leave', this.#apiKey);
};
/**
* Removes all events, subscriptions and DOM nodes created by this component.
*/
destroy() {
this.#restoreOrientation();
this.#player.removeApi(['fullscreen.enter', 'fullscreen.leave'], this.#apiKey);
this.#player.removeState('ui.fullscreen', this.#apiKey);
clearTimeout(this.#isPlayingDelay);
document.removeEventListener(this.#fsApi.fullscreenchange, this.#onFullScreen);
this.#dom.destroy();
this.#player.unsubscribe(this.#subscriptions);
this.#player = this.#dom = this.#apiKey = null;
}
/**
* Determines and applies the desired orientation lock when entering fullscreen.
*/
#lockOrientation() {
if (!screen?.orientation?.lock) return;
const targetOrientation = this.#getOrientationLockTarget();
if (!targetOrientation) {
this.#previousOrientation = null;
return;
}
this.#previousOrientation = screen.orientation.type;
try {
const lockPromise = screen.orientation.lock(targetOrientation);
if (lockPromise?.then) {
lockPromise.then(() => { this.#orientationLocked = true; }).catch(() => {});
} else {
this.#orientationLocked = true;
}
} catch {
// ignore orientation lock failures
}
}
/**
* Restores the previous orientation if a lock had been applied.
* @param {boolean} [preExit=false] If true, relock to previous orientation while still in fullscreen (before exiting).
*/
#restoreOrientation(preExit = false) {
if (!screen?.orientation) return;
if (preExit && this.#previousOrientation && screen.orientation.lock) {
try { screen.orientation.lock(this.#previousOrientation).catch(() => {}); } catch {}
}
if (this.#orientationLocked && typeof screen.orientation.unlock === 'function') {
try { screen.orientation.unlock(); } catch {}
}
this.#orientationLocked = false;
this.#previousOrientation = null;
}
/**
* Evaluates which orientation should be locked based on config, media and device orientation.
* @returns {string|null} Desired orientation lock or null if no lock should be applied.
*/
#getOrientationLockTarget() {
const option = String(this.#config?.orientationLock ?? '').toLowerCase(),
mode = option === 'portait' ? 'portrait' : option;
switch (mode) {
case 'landscape':
case 'portrait':
return mode;
case 'auto': {
const width = this.#player.getState('media.videoWidth'),
height = this.#player.getState('media.videoHeight'),
isLandscapeVideo = width && height ? width >= height : false,
isPortraitDevice = screen?.orientation?.type
? screen.orientation.type.includes('portrait')
: window.matchMedia?.('(orientation: portrait)').matches ?? false;
return isPortraitDevice && isLandscapeVideo ? 'landscape' : null;
}
default:
return null;
}
}
}
/**
* The object used to map browser specific fullscreen API names to the 'official' ones.
* @typedef {Object<string>} module:src/controller/FullScreen~fsApiNames
* @property {string} exitFullscreen Name for the method which is used for exiting fullscreen mode.
* @property {string} fullscreenElement Returns the Element that is currently being presented in full-screen mode in this document, or null if full-screen mode is not currently in use.
* @property {string} fullscreenEnabled Name for the property which returns a Boolean that reports whether or not full-screen mode is available.
* @property {string} fullscreenchange Name for the onfullscreenchange event, which is fired when the browser is switched to/out-of fullscreen mode.
* @property {string} fullscreenerror Name for the fullscreenerror event, which is fired when the browser cannot switch to fullscreen mode.
* @property {string} requestFullscreen Name for the requestFullscreen method, which issues an asynchronous request to make the element be displayed full-screen.
*/
/**
* Fired when the player enters fullscreen mode.
* @event module:src/controller/FullScreen#fullscreen/enter
*/
/**
* Fired when the player exits fullscreen mode.
* @event module:src/controller/FullScreen#fullscreen/leave
*/