import browser from '../../scripts/browser';
import { appHost } from '../../components/apphost';
import loading from '../../components/loading/loading';
import dom from '../../scripts/dom';
import { playbackManager } from '../../components/playback/playbackmanager';
import { appRouter } from '../../components/appRouter';
import {
bindEventsToHlsPlayer,
destroyHlsPlayer,
destroyFlvPlayer,
destroyCastPlayer,
getCrossOriginValue,
enableHlsJsPlayer,
applySrc,
resetSrc,
playWithPromise,
onEndedInternal,
saveVolume,
seekOnPlaybackStart,
onErrorInternal,
handleHlsJsMediaError,
getSavedVolume,
isValidDuration,
getBufferedRanges
} from '../../components/htmlMediaHelper';
import itemHelper from '../../components/itemHelper';
import Screenfull from 'screenfull';
import globalize from '../../scripts/globalize';
import ServerConnections from '../../components/ServerConnections';
import profileBuilder, { canPlaySecondaryAudio } from '../../scripts/browserDeviceProfile';
import { getIncludeCorsCredentials } from '../../scripts/settings/webSettings';
import { setBackdropTransparency, TRANSPARENCY_LEVEL } from '../../components/backdrop/backdrop';
import Events from '../../utils/events.ts';
import { includesAny } from '../../utils/container.ts';
/**
* Returns resolved URL.
* @param {string} url - URL.
* @returns {string} Resolved URL or `url` if resolving failed.
*/
function resolveUrl(url) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('HEAD', url, true);
xhr.onload = function () {
resolve(xhr.responseURL || url);
};
xhr.onerror = function (e) {
console.error(e);
resolve(url);
};
xhr.send(null);
});
}
/* eslint-disable indent */
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}`);
}
}
}
function enableNativeTrackSupport(currentSrc, track) {
if (track?.DeliveryMethod === 'Embed') {
return true;
}
if (browser.firefox && (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 && (browser.iosVersion || 10) < 10) {
// works in the browser but not the native app
return false;
}
if (track) {
const format = (track.Codec || '').toLowerCase();
if (format === 'ssa' || format === 'ass') {
return false;
}
}
return true;
}
function requireHlsPlayer(callback) {
import('hls.js').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 zoomIn(elem) {
return new Promise(resolve => {
const duration = 240;
elem.style.animation = `htmlvideoplayer-zoomin ${duration}ms ease-in normal`;
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 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 getDefaultProfile() {
return profileBuilder({});
}
const PRIMARY_TEXT_TRACK_INDEX = 0;
const SECONDARY_TEXT_TRACK_INDEX = 1;
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 | undefined}
*/
#secondarySubtitleTrackIndexToSetOnPlaying;
/**
* @type {number | null}
*/
#audioTrackIndexToSetOnPlaying;
/**
* @type {null | undefined}
*/
#currentClock;
/**
* @type {any | null | undefined}
*/
#currentJASSUB;
/**
* @type {null | undefined}
*/
#currentAssRenderer;
/**
* @type {number | undefined}
*/
#customTrackIndex;
/**
* @type {number | undefined}
*/
#customSecondaryTrackIndex;
/**
* @type {boolean | undefined}
*/
#showTrackOffset;
/**
* @type {number | undefined}
*/
#currentTrackOffset;
/**
* @type {HTMLElement | null | undefined}
*/
#secondaryTrackOffset;
/**
* @type {HTMLElement | null | undefined}
*/
#videoSubtitlesElem;
/**
* @type {HTMLElement | null | undefined}
*/
#videoSecondarySubtitlesElem;
/**
* @type {any | null | undefined}
*/
#currentTrackEvents;
/**
* @type {any | null | undefined}
*/
#currentSecondaryTrackEvents;
/**
* @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;
/**
* @private (used in other files)
* @type {any | null | undefined}
*/
_castPlayer;
/**
* @private (used in other files)
* @type {any | undefined}
*/
_currentPlayOptions;
/**
* @type {any | undefined}
*/
#lastProfile;
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 ServerConnections.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('flv.js').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(elem, options, url) {
return new Promise((resolve, reject) => {
requireHlsPlayer(async () => {
let maxBufferLength = 30;
// Some browsers cannot handle huge fragments in high bitrate.
// This issue usually happens when using HWA encoders with a high bitrate setting.
// Limit the BufferLength to 6s, it works fine when playing 4k 120Mbps over HLS on chrome.
// https://github.com/video-dev/hls.js/issues/876
if ((browser.chrome || browser.edgeChromium || browser.firefox) && playbackManager.getMaxStreamingBitrate(this) >= 25000000) {
maxBufferLength = 6;
}
const includeCorsCredentials = await getIncludeCorsCredentials();
const hls = new Hls({
manifestLoadingTimeOut: 20000,
maxBufferLength: maxBufferLength,
xhrSetup(xhr) {
xhr.withCredentials = includeCorsCredentials;
}
});
hls.loadSource(url);
hls.attachMedia(elem);
bindEventsToHlsPlayer(this, hls, elem, this.onError, resolve, reject);
this._hlsPlayer = hls;
// This is needed in setCurrentTrackElement
this.#currentSrc = url;
});
});
}
/**
* @private
*/
async 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}`;
}
destroyHlsPlayer(this);
destroyFlvPlayer(this);
destroyCastPlayer(this);
let secondaryTrackValid = true;
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;
secondaryTrackValid = false;
}
// secondary track should not be shown if primary track is no longer a valid pair
if (initialSubtitleStream && !playbackManager.trackHasSecondarySubtitleSupport(initialSubtitleStream, this)) {
secondaryTrackValid = false;
}
} else {
secondaryTrackValid = false;
}
this.#audioTrackIndexToSetOnPlaying = options.playMethod === 'Transcode' ? null : options.mediaSource.DefaultAudioStreamIndex;
this._currentPlayOptions = options;
if (secondaryTrackValid) {
this.#secondarySubtitleTrackIndexToSetOnPlaying = options.mediaSource.DefaultSecondarySubtitleStreamIndex == null ? -1 : options.mediaSource.DefaultSecondarySubtitleStreamIndex;
if (this.#secondarySubtitleTrackIndexToSetOnPlaying != null && this.#secondarySubtitleTrackIndexToSetOnPlaying >= 0) {
const initialSecondarySubtitleStream = options.mediaSource.MediaStreams[this.#secondarySubtitleTrackIndexToSetOnPlaying];
if (!initialSecondarySubtitleStream || !playbackManager.trackHasSecondarySubtitleSupport(initialSecondarySubtitleStream, this)) {
this.#secondarySubtitleTrackIndexToSetOnPlaying = -1;
}
}
} else {
this.#secondarySubtitleTrackIndexToSetOnPlaying = -1;
}
const crossOrigin = getCrossOriginValue(options.mediaSource);
if (crossOrigin) {
elem.crossOrigin = crossOrigin;
}
if (enableHlsJsPlayer(options.mediaSource.RunTimeTicks, 'Video') && val.includes('.m3u8')) {
return this.setSrcWithHlsJs(elem, options, val);
} else if (options.playMethod !== 'Transcode' && options.mediaSource.Container === 'flv') {
return this.setSrcWithFlvJs(elem, options, val);
} else {
elem.autoplay = true;
const includeCorsCredentials = await getIncludeCorsCredentials();
if (includeCorsCredentials) {
// Safari will not send cookies without this
elem.crossOrigin = 'use-credentials';
}
return applySrc(elem, val, options).then(() => {
this.#currentSrc = val;
return playWithPromise(elem, this.onError);
});
}
}
setSubtitleStreamIndex(index) {
this.setCurrentTrackElement(index);
}
setSecondarySubtitleStreamIndex(index) {
this.setCurrentTrackElement(index, SECONDARY_TEXT_TRACK_INDEX);
}
resetSubtitleOffset() {
this.#currentTrackOffset = 0;
this.#secondaryTrackOffset = 0;
this.#showTrackOffset = false;
}
enableShowingSubtitleOffset() {
this.#showTrackOffset = true;
}
disableShowingSubtitleOffset() {
this.#showTrackOffset = false;
}
isShowingSubtitleOffsetEnabled() {
return this.#showTrackOffset;
}
/**
* @private
*/
getTextTracks() {
const videoElement = this.#mediaElement;
if (videoElement) {
return Array.from(videoElement.textTracks)
.filter(function (trackElement) {
// get showing .vtt textTack
return trackElement.mode === 'showing';
});
} else {
return null;
}
}
setSubtitleOffset(offset) {
const offsetValue = parseFloat(offset);
// if .ass currently rendering
if (this.#currentJASSUB) {
this.updateCurrentTrackOffset(offsetValue);
this.#currentJASSUB.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + offsetValue;
} else {
const trackElements = this.getTextTracks();
// if .vtt currently rendering
if (trackElements?.length > 0) {
trackElements.forEach((trackElement, index) => {
this.setTextTrackSubtitleOffset(trackElement, offsetValue, index);
});
} else if (this.#currentTrackEvents || this.#currentSecondaryTrackEvents) {
this.#currentTrackEvents && this.setTrackEventsSubtitleOffset(this.#currentTrackEvents, offsetValue, PRIMARY_TEXT_TRACK_INDEX);
this.#currentSecondaryTrackEvents && this.setTrackEventsSubtitleOffset(this.#currentSecondaryTrackEvents, offsetValue, SECONDARY_TEXT_TRACK_INDEX);
} else {
console.debug('No available track, cannot apply offset: ', offsetValue);
}
}
}
/**
* @private
*/
updateCurrentTrackOffset(offsetValue, currentTrackIndex = PRIMARY_TEXT_TRACK_INDEX) {
let offsetToCompare = this.#currentTrackOffset;
if (this.isSecondaryTrack(currentTrackIndex)) {
offsetToCompare = this.#secondaryTrackOffset;
}
let relativeOffset = offsetValue;
const newTrackOffset = offsetValue;
if (offsetToCompare) {
relativeOffset -= offsetToCompare;
}
if (this.isSecondaryTrack(currentTrackIndex)) {
this.#secondaryTrackOffset = newTrackOffset;
} else {
this.#currentTrackOffset = newTrackOffset;
}
// relative to currentTrackOffset
return relativeOffset;
}
/**
* @private
*/
setTextTrackSubtitleOffset(currentTrack, offsetValue, currentTrackIndex) {
if (currentTrack.cues) {
offsetValue = this.updateCurrentTrackOffset(offsetValue, currentTrackIndex);
if (offsetValue === 0) {
return;
}
Array.from(currentTrack.cues)
.forEach(function (cue) {
cue.startTime -= offsetValue;
cue.endTime -= offsetValue;
});
}
}
/**
* @private
*/
setTrackEventsSubtitleOffset(trackEvents, offsetValue, currentTrackIndex) {
if (Array.isArray(trackEvents)) {
offsetValue = this.updateCurrentTrackOffset(offsetValue, currentTrackIndex) * 1e7; // ticks
if (offsetValue === 0) {
return;
}
trackEvents.forEach(function (trackEvent) {
trackEvent.StartPositionTicks -= offsetValue;
trackEvent.EndPositionTicks -= offsetValue;
});
}
}
getSubtitleOffset() {
return this.#currentTrackOffset;
}
isPrimaryTrack(textTrackIndex) {
return textTrackIndex === PRIMARY_TEXT_TRACK_INDEX;
}
isSecondaryTrack(textTrackIndex) {
return textTrackIndex === SECONDARY_TEXT_TRACK_INDEX;
}
/**
* @private
*/
isAudioStreamSupported(stream, deviceProfile, container) {
const codec = (stream.Codec || '').toLowerCase();
if (!codec) {
return true;
}
if (!deviceProfile) {
// This should never happen
return true;
}
const profiles = deviceProfile.DirectPlayProfiles || [];
return profiles.some(function (p) {
return p.Type === 'Video'
&& includesAny((p.Container || '').toLowerCase(), container)
&& includesAny((p.AudioCodec || '').toLowerCase(), codec);
});
}
/**
* @private
*/
getSupportedAudioStreams() {
const profile = this.#lastProfile;
const mediaSource = this._currentPlayOptions.mediaSource;
const container = mediaSource.Container.toLowerCase();
return getMediaStreamAudioTracks(mediaSource).filter((stream) => {
return this.isAudioStreamSupported(stream, profile, container);
});
}
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://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/audioTracks
/**
* @type {ArrayLike|any[]}
*/
const elemAudioTracks = elem.audioTracks || [];
console.debug(`found ${elemAudioTracks.length} audio tracks`);
for (const [i, audioTrack] of Array.from(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();
}
onEndedInternal(this, elem, this.onError);
}
this.destroyCustomTrack(elem);
if (destroyPlayer) {
this.destroy();
}
return Promise.resolve();
}
destroy() {
destroyHlsPlayer(this);
destroyFlvPlayer(this);
setBackdropTransparency(TRANSPARENCY_LEVEL.None);
document.body.classList.remove('hide-scroll');
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.removeEventListener('error', this.onError); // bound in htmlMediaHelper
resetSrc(videoElement);
videoElement.parentNode.removeChild(videoElement);
}
const dlg = this.#videoDialog;
if (dlg) {
this.#videoDialog = null;
dlg.parentNode.removeChild(dlg);
}
if (Screenfull.isEnabled) {
Screenfull.exit();
} else {
// iOS Safari
if (document.webkitIsFullScreen && document.webkitCancelFullscreen) {
document.webkitCancelFullscreen();
}
}
}
/**
* @private
* @param e {Event} The event received from the `