import browser from 'browser';
import events from 'events';
import appHost from 'apphost';
import loading from 'loading';
import dom from 'dom';
import playbackManager from 'playbackManager';
import appRouter from 'appRouter';
import connectionManager from 'connectionManager';
import htmlMediaHelper from 'htmlMediaHelper';
import itemHelper from 'itemHelper';
import screenfull from 'screenfull';
import globalize from 'globalize';
/* eslint-disable indent */
/* globals cast */
let mediaManager;
function tryRemoveElement(elem) {
const parentNode = elem.parentNode;
if (parentNode) {
// Seeing crashes in edge webview
try {
parentNode.removeChild(elem);
} catch (err) {
console.error(`error removing dialog element: ${err}`);
}
}
}
let _supportsTextTracks;
function supportsTextTracks() {
if (_supportsTextTracks == null) {
_supportsTextTracks = document.createElement('video').textTracks != null;
}
// For now, until ready
return _supportsTextTracks;
}
function supportsCanvas() {
return !!document.createElement('canvas').getContext;
}
function supportsWebWorkers() {
return !!window.Worker;
}
function enableNativeTrackSupport(currentSrc, track) {
if (track) {
if (track.DeliveryMethod === 'Embed') {
return true;
}
}
if (browser.firefox) {
if ((currentSrc || '').toLowerCase().includes('.m3u8')) {
return false;
}
}
// subs getting blocked due to CORS
if (browser.chromecast) {
if ((currentSrc || '').toLowerCase().includes('.m3u8')) {
return false;
}
}
if (browser.ps4) {
return false;
}
if (browser.web0s) {
return false;
}
// Edge is randomly not rendering subtitles
if (browser.edge) {
return false;
}
if (browser.iOS) {
// works in the browser but not the native app
if ((browser.iosVersion || 10) < 10) {
return false;
}
}
if (track) {
const format = (track.Codec || '').toLowerCase();
if (format === 'ssa' || format === 'ass') {
return false;
}
}
return true;
}
function requireHlsPlayer(callback) {
import('hlsjs').then(({default: hls}) => {
window.Hls = hls;
callback();
});
}
function getMediaStreamAudioTracks(mediaSource) {
return mediaSource.MediaStreams.filter(function (s) {
return s.Type === 'Audio';
});
}
function getMediaStreamTextTracks(mediaSource) {
return mediaSource.MediaStreams.filter(function (s) {
return s.Type === 'Subtitle';
});
}
function hidePrePlaybackPage() {
let animatedPage = document.querySelector('.page:not(.hide)');
animatedPage.classList.add('hide');
// At this point, we must hide the scrollbar placeholder, so it's not being displayed while the item is being loaded
document.body.classList.remove('force-scroll');
}
function zoomIn(elem) {
return new Promise(function (resolve, reject) {
const duration = 240;
elem.style.animation = `htmlvideoplayer-zoomin ${duration}ms ease-in normal`;
hidePrePlaybackPage();
dom.addEventListener(elem, dom.whichAnimationEvent(), resolve, {
once: true
});
});
}
function normalizeTrackEventText(text, useHtml) {
const result = text.replace(/\\N/gi, '\n').replace(/\r/gi, '');
return useHtml ? result.replace(/\n/gi, '
') : result;
}
function setTracks(elem, tracks, item, mediaSource) {
elem.innerHTML = getTracksHtml(tracks, item, mediaSource);
}
function getTextTrackUrl(track, item, format) {
if (itemHelper.isLocalItem(item) && track.Path) {
return track.Path;
}
let url = playbackManager.getSubtitleUrl(track, item.ServerId);
if (format) {
url = url.replace('.vtt', format);
}
return url;
}
function getTracksHtml(tracks, item, mediaSource) {
return tracks.map(function (t) {
if (t.DeliveryMethod !== 'External') {
return '';
}
const defaultAttribute = mediaSource.DefaultSubtitleStreamIndex === t.Index ? ' default' : '';
const language = t.Language || 'und';
const label = t.Language || 'und';
return ``;
}).join('');
}
function getDefaultProfile() {
return import('browserdeviceprofile').then(({default: profileBuilder}) => {
return profileBuilder({});
});
}
export class HtmlVideoPlayer {
/**
* @type {string}
*/
name
/**
* @type {string}
*/
type = 'mediaplayer';
/**
* @type {string}
*/
id = 'htmlvideoplayer';
/**
* Let any players created by plugins take priority
*
* @type {number}
*/
priority = 1;
/**
* @type {boolean}
*/
isFetching = false;
/**
* @type {HTMLDivElement | null | undefined}
*/
#videoDialog;
/**
* @type {number | undefined}
*/
#subtitleTrackIndexToSetOnPlaying;
/**
* @type {number | null}
*/
#audioTrackIndexToSetOnPlaying;
/**
* @type {number | undefined}
*/
#lastCustomTrackMs;
/**
* @type {null | undefined}
*/
#currentClock;
/**
* @type {any | null | undefined}
*/
#currentSubtitlesOctopus;
/**
* @type {null | undefined}
*/
#currentAssRenderer;
/**
* @type {number | undefined}
*/
#customTrackIndex;
/**
* @type {boolean | undefined}
*/
#showTrackOffset;
/**
* @type {number | undefined}
*/
#currentTrackOffset;
/**
* @type {HTMLElement | null | undefined}
*/
#videoSubtitlesElem;
/**
* @type {any | null | undefined}
*/
#currentTrackEvents;
/**
* @type {string[] | undefined}
*/
#supportedFeatures;
/**
* @type {HTMLVideoElement | null | undefined}
*/
#mediaElement;
/**
* @type {number}
*/
#fetchQueue = 0;
/**
* @type {string | undefined}
*/
#currentSrc;
/**
* @type {boolean | undefined}
*/
#started;
/**
* @type {boolean | undefined}
*/
#timeUpdated;
/**
* @type {number | null | undefined}
*/
#currentTime;
/**
* @type {any | undefined}
*/
#flvPlayer;
/**
* @private (used in other files)
* @type {any | undefined}
*/
_hlsPlayer;
/**
* @type {any | undefined}
*/
#shakaPlayer;
/**
* @private (used in other files)
* @type {any | null | undefined}
*/
_castPlayer;
/**
* @private (used in other files)
* @type {any | undefined}
*/
_currentPlayOptions;
/**
* @type {any | undefined}
*/
#lastProfile;
/**
* @type {MutationObserver | IntersectionObserver | undefined} (Unclear observer typing)
*/
#resizeObserver;
constructor() {
if (browser.edgeUwp) {
this.name = 'Windows Video Player';
} else {
this.name = 'Html Video Player';
}
}
currentSrc() {
return this.#currentSrc;
}
/**
* @private
*/
incrementFetchQueue() {
if (this.#fetchQueue <= 0) {
this.isFetching = true;
events.trigger(this, 'beginFetch');
}
this.#fetchQueue++;
}
/**
* @private
*/
decrementFetchQueue() {
this.#fetchQueue--;
if (this.#fetchQueue <= 0) {
this.isFetching = false;
events.trigger(this, 'endFetch');
}
}
/**
* @private
*/
updateVideoUrl(streamInfo) {
const isHls = streamInfo.url.toLowerCase().includes('.m3u8');
const mediaSource = streamInfo.mediaSource;
const item = streamInfo.item;
// Huge hack alert. Safari doesn't seem to like if the segments aren't available right away when playback starts
// This will start the transcoding process before actually feeding the video url into the player
// Edit: Also seeing stalls from hls.js
if (mediaSource && item && !mediaSource.RunTimeTicks && isHls && streamInfo.playMethod === 'Transcode' && (browser.iOS || browser.osx)) {
const hlsPlaylistUrl = streamInfo.url.replace('master.m3u8', 'live.m3u8');
loading.show();
console.debug(`prefetching hls playlist: ${hlsPlaylistUrl}`);
return connectionManager.getApiClient(item.ServerId).ajax({
type: 'GET',
url: hlsPlaylistUrl
}).then(function () {
console.debug(`completed prefetching hls playlist: ${hlsPlaylistUrl}`);
loading.hide();
streamInfo.url = hlsPlaylistUrl;
}, function () {
console.error(`error prefetching hls playlist: ${hlsPlaylistUrl}`);
loading.hide();
});
} else {
return Promise.resolve();
}
}
play(options) {
this.#started = false;
this.#timeUpdated = false;
this.#currentTime = null;
this.resetSubtitleOffset();
return this.createMediaElement(options).then(elem => {
return this.updateVideoUrl(options).then(() => {
return this.setCurrentSrc(elem, options);
});
});
}
/**
* @private
*/
setSrcWithFlvJs(elem, options, url) {
return import('flvjs').then(({default: flvjs}) => {
const flvPlayer = flvjs.createPlayer({
type: 'flv',
url: url
},
{
seekType: 'range',
lazyLoad: false
});
flvPlayer.attachMediaElement(elem);
flvPlayer.load();
this.#flvPlayer = flvPlayer;
// This is needed in setCurrentTrackElement
this.#currentSrc = url;
return flvPlayer.play();
});
}
/**
* @private
*/
setSrcWithHlsJs(instance, elem, options, url) {
return new Promise((resolve, reject) => {
requireHlsPlayer(() => {
const hls = new Hls({
manifestLoadingTimeOut: 20000,
xhrSetup(xhr, xhr_url) {
xhr.withCredentials = true;
}
//appendErrorMaxRetry: 6,
//debug: true
});
hls.loadSource(url);
hls.attachMedia(elem);
htmlMediaHelper.bindEventsToHlsPlayer(this, hls, elem, this.onError, resolve, reject);
this._hlsPlayer = hls;
// This is needed in setCurrentTrackElement
this.#currentSrc = url;
});
});
}
/**
* @private
*/
onShakaError = (event) => {
const error = event.detail;
console.error(`Error code: ${error.code}\nObject: `, error);
}
/**
* @private
*/
setSrcWithShakaPlayer(instance, elem, options, url) {
return import('shaka').then(() => {
/* globals shaka */
const player = new shaka.Player(elem);
//player.configure({
// abr: {
// enabled: false
// },
// streaming: {
// failureCallback: function () {
// alert(2);
// }
// }
//});
//shaka.log.setLevel(6);
// Listen for error events.
player.addEventListener('error', this.onShakaError);
this.#shakaPlayer = player;
// This is needed in setCurrentTrackElement
this.#currentSrc = url;
// Try to load a manifest.
// This is an asynchronous process.
return player.load(url);
});
}
/**
* @private
*/
setCurrentSrcChromecast(instance, elem, options, url) {
elem.autoplay = true;
const lrd = new cast.receiver.MediaManager.LoadRequestData();
lrd.currentTime = (options.playerStartPositionTicks || 0) / 10000000;
lrd.autoplay = true;
lrd.media = new cast.receiver.media.MediaInformation();
lrd.media.contentId = url;
lrd.media.contentType = options.mimeType;
lrd.media.streamType = cast.receiver.media.StreamType.OTHER;
lrd.media.customData = options;
console.debug('loading media url into media manager');
try {
mediaManager.load(lrd);
// This is needed in setCurrentTrackElement
this.#currentSrc = url;
return Promise.resolve();
} catch (err) {
console.debug(`media manager error: ${err}`);
return Promise.reject();
}
}
/**
* Adapted from : https://github.com/googlecast/CastReferencePlayer/blob/master/player.js
* @private
*/
onMediaManagerLoadMedia = (event) => {
if (this._castPlayer) {
this._castPlayer.unload(); // Must unload before starting again.
}
this._castPlayer = null;
const data = event.data;
const media = event.data.media || {};
const url = media.contentId;
const contentType = media.contentType.toLowerCase();
const options = media.customData;
let protocol;
const ext = 'm3u8';
const mediaElement = this.#mediaElement;
const host = new cast.player.api.Host({
'url': url,
'mediaElement': mediaElement
});
if (ext === 'm3u8' ||
contentType === 'application/x-mpegurl' ||
contentType === 'application/vnd.apple.mpegurl') {
protocol = cast.player.api.CreateHlsStreamingProtocol(host);
} else if (ext === 'mpd' ||
contentType === 'application/dash+xml') {
protocol = cast.player.api.CreateDashStreamingProtocol(host);
} else if (url.includes('.ism') ||
contentType === 'application/vnd.ms-sstr+xml') {
protocol = cast.player.api.CreateSmoothStreamingProtocol(host);
}
console.debug(`loading playback url: ${url}`);
console.debug(`content type: ${contentType}`);
host.onError = function (errorCode) {
console.error(`fatal Error - ${errorCode}`);
};
mediaElement.autoplay = false;
this._castPlayer = new cast.player.api.Player(host);
this._castPlayer.load(protocol, data.currentTime || 0);
this._castPlayer.playWhenHaveEnoughData();
}
/**
* @private
*/
initMediaManager() {
mediaManager.defaultOnLoad = mediaManager.onLoad.bind(mediaManager);
mediaManager.onLoad = this.onMediaManagerLoadMedia;
//mediaManager.defaultOnPlay = mediaManager.onPlay.bind(mediaManager);
//mediaManager.onPlay = function (event) {
// // TODO ???
// mediaManager.defaultOnPlay(event);
//};
mediaManager.defaultOnStop = mediaManager.onStop.bind(mediaManager);
mediaManager.onStop = function (event) {
playbackManager.stop();
mediaManager.defaultOnStop(event);
};
}
/**
* @private
*/
setCurrentSrc(elem, options) {
elem.removeEventListener('error', this.onError);
let val = options.url;
console.debug(`playing url: ${val}`);
// Convert to seconds
const seconds = (options.playerStartPositionTicks || 0) / 10000000;
if (seconds) {
val += `#t=${seconds}`;
}
htmlMediaHelper.destroyHlsPlayer(this);
htmlMediaHelper.destroyFlvPlayer(this);
htmlMediaHelper.destroyCastPlayer(this);
const tracks = getMediaStreamTextTracks(options.mediaSource);
this.#subtitleTrackIndexToSetOnPlaying = options.mediaSource.DefaultSubtitleStreamIndex == null ? -1 : options.mediaSource.DefaultSubtitleStreamIndex;
if (this.#subtitleTrackIndexToSetOnPlaying != null && this.#subtitleTrackIndexToSetOnPlaying >= 0) {
const initialSubtitleStream = options.mediaSource.MediaStreams[this.#subtitleTrackIndexToSetOnPlaying];
if (!initialSubtitleStream || initialSubtitleStream.DeliveryMethod === 'Encode') {
this.#subtitleTrackIndexToSetOnPlaying = -1;
}
}
this.#audioTrackIndexToSetOnPlaying = options.playMethod === 'Transcode' ? null : options.mediaSource.DefaultAudioStreamIndex;
this._currentPlayOptions = options;
const crossOrigin = htmlMediaHelper.getCrossOriginValue(options.mediaSource);
if (crossOrigin) {
elem.crossOrigin = crossOrigin;
}
/*if (htmlMediaHelper.enableHlsShakaPlayer(options.item, options.mediaSource, 'Video') && val.includes('.m3u8')) {
setTracks(elem, tracks, options.item, options.mediaSource);
return setSrcWithShakaPlayer(this, elem, options, val);
} else*/
if (browser.chromecast && val.includes('.m3u8') && options.mediaSource.RunTimeTicks) {
return this.setCurrentSrcChromecast(this, elem, options, val);
} else if (htmlMediaHelper.enableHlsJsPlayer(options.mediaSource.RunTimeTicks, 'Video') && val.includes('.m3u8')) {
return this.setSrcWithHlsJs(this, elem, options, val);
} else if (options.playMethod !== 'Transcode' && options.mediaSource.Container === 'flv') {
return this.setSrcWithFlvJs(elem, options, val);
} else {
elem.autoplay = true;
// Safari will not send cookies without this
elem.crossOrigin = 'use-credentials';
return htmlMediaHelper.applySrc(elem, val, options).then(() => {
this.#currentSrc = val;
return htmlMediaHelper.playWithPromise(elem, this.onError);
});
}
}
setSubtitleStreamIndex(index) {
this.setCurrentTrackElement(index);
}
resetSubtitleOffset() {
this.#currentTrackOffset = 0;
this.#showTrackOffset = false;
}
enableShowingSubtitleOffset() {
this.#showTrackOffset = true;
}
disableShowingSubtitleOffset() {
this.#showTrackOffset = false;
}
isShowingSubtitleOffsetEnabled() {
return this.#showTrackOffset;
}
/**
* @private
*/
getTextTrack() {
const videoElement = this.#mediaElement;
if (videoElement) {
return Array.from(videoElement.textTracks)
.find(function (trackElement) {
// get showing .vtt textTack
return trackElement.mode === 'showing';
});
} else {
return null;
}
}
/**
* @private
*/
setSubtitleOffset(offset) {
const offsetValue = parseFloat(offset);
// if .ass currently rendering
if (this.#currentSubtitlesOctopus) {
this.updateCurrentTrackOffset(offsetValue);
this.#currentSubtitlesOctopus.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + offsetValue;
} else {
const trackElement = this.getTextTrack();
// if .vtt currently rendering
if (trackElement) {
this.setTextTrackSubtitleOffset(trackElement, offsetValue);
} else if (this.#currentTrackEvents) {
this.setTrackEventsSubtitleOffset(this.#currentTrackEvents, offsetValue);
} else {
console.debug('No available track, cannot apply offset: ', offsetValue);
}
}
}
/**
* @private
*/
updateCurrentTrackOffset(offsetValue) {
let relativeOffset = offsetValue;
const newTrackOffset = offsetValue;
if (this.#currentTrackOffset) {
relativeOffset -= this.#currentTrackOffset;
}
this.#currentTrackOffset = newTrackOffset;
// relative to currentTrackOffset
return relativeOffset;
}
/**
* @private
*/
setTextTrackSubtitleOffset(currentTrack, offsetValue) {
if (currentTrack.cues) {
offsetValue = this.updateCurrentTrackOffset(offsetValue);
Array.from(currentTrack.cues)
.forEach(function (cue) {
cue.startTime -= offsetValue;
cue.endTime -= offsetValue;
});
}
}
/**
* @private
*/
setTrackEventsSubtitleOffset(trackEvents, offsetValue) {
if (Array.isArray(trackEvents)) {
offsetValue = this.updateCurrentTrackOffset(offsetValue) * 1e7; // ticks
trackEvents.forEach(function (trackEvent) {
trackEvent.StartPositionTicks -= offsetValue;
trackEvent.EndPositionTicks -= offsetValue;
});
}
}
getSubtitleOffset() {
return this.#currentTrackOffset;
}
/**
* @private
*/
isAudioStreamSupported(stream, deviceProfile) {
const codec = (stream.Codec || '').toLowerCase();
if (!codec) {
return true;
}
if (!deviceProfile) {
// This should never happen
return true;
}
const profiles = deviceProfile.DirectPlayProfiles || [];
return profiles.filter(function (p) {
if (p.Type === 'Video') {
if (!p.AudioCodec) {
return true;
}
return p.AudioCodec.toLowerCase().includes(codec);
}
return false;
}).length > 0;
}
/**
* @private
*/
getSupportedAudioStreams() {
const profile = this.#lastProfile;
return getMediaStreamAudioTracks(this._currentPlayOptions.mediaSource).filter((stream) => {
return this.isAudioStreamSupported(stream, profile);
});
}
setAudioStreamIndex(index) {
const streams = this.getSupportedAudioStreams();
if (streams.length < 2) {
// If there's only one supported stream then trust that the player will handle it on it's own
return;
}
let audioIndex = -1;
for (const stream of streams) {
audioIndex++;
if (stream.Index === index) {
break;
}
}
if (audioIndex === -1) {
return;
}
const elem = this.#mediaElement;
if (!elem) {
return;
}
// https://msdn.microsoft.com/en-us/library/hh772507(v=vs.85).aspx
const elemAudioTracks = elem.audioTracks || [];
console.debug(`found ${elemAudioTracks.length} audio tracks`);
for (const [i, audioTrack] of elemAudioTracks.entries()) {
if (audioIndex === i) {
console.debug(`setting audio track ${i} to enabled`);
audioTrack.enabled = true;
} else {
console.debug(`setting audio track ${i} to disabled`);
audioTrack.enabled = false;
}
}
}
stop(destroyPlayer) {
const elem = this.#mediaElement;
const src = this.#currentSrc;
if (elem) {
if (src) {
elem.pause();
}
htmlMediaHelper.onEndedInternal(this, elem, this.onError);
if (destroyPlayer) {
this.destroy();
}
}
this.destroyCustomTrack(elem);
return Promise.resolve();
}
destroy() {
htmlMediaHelper.destroyHlsPlayer(this);
htmlMediaHelper.destroyFlvPlayer(this);
appRouter.setTransparency('none');
const videoElement = this.#mediaElement;
if (videoElement) {
this.#mediaElement = null;
this.destroyCustomTrack(videoElement);
videoElement.removeEventListener('timeupdate', this.onTimeUpdate);
videoElement.removeEventListener('ended', this.onEnded);
videoElement.removeEventListener('volumechange', this.onVolumeChange);
videoElement.removeEventListener('pause', this.onPause);
videoElement.removeEventListener('playing', this.onPlaying);
videoElement.removeEventListener('play', this.onPlay);
videoElement.removeEventListener('click', this.onClick);
videoElement.removeEventListener('dblclick', this.onDblClick);
videoElement.removeEventListener('waiting', this.onWaiting);
videoElement.parentNode.removeChild(videoElement);
}
const dlg = this.#videoDialog;
if (dlg) {
this.#videoDialog = null;
dlg.parentNode.removeChild(dlg);
}
if (screenfull.isEnabled) {
screenfull.exit();
}
}
/**
* @private
* @param e {Event} The event received from the `