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 `