import browser from '../../scripts/browser'; import { Events } from 'jellyfin-apiclient'; 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, 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 from '../../scripts/browserDeviceProfile'; import { getIncludeCorsCredentials } from '../../scripts/settings/webSettings'; /* 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) { if (track.DeliveryMethod === 'Embed') { return true; } } if (browser.firefox) { 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('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({}); } 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 {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; /** * @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 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); 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 = 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); } 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://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); if (destroyPlayer) { this.destroy(); } } this.destroyCustomTrack(elem); return Promise.resolve(); } destroy() { destroyHlsPlayer(this); destroyFlvPlayer(this); appRouter.setTransparency('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.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 `