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({}); }); } /** * Private: * - videoDialog * - winJsPlaybackItem * - subtitleTrackIndexToSetOnPlaying * - audioTrackIndexToSetOnPlaying * - lastCustomTrackMs * - currentClock * - currentSubtitlesOctopus * - currentAssRenderer * - customTrackIndex * - showTrackOffset * - currentTrackOffset * - videoSubtitlesElem * - currentTrackEvents * - supportedFeatures */ export class HtmlVideoPlayer { constructor() { if (browser.edgeUwp) { this.name = 'Windows Video Player'; } else { this.name = 'Html Video Player'; } this.type = 'mediaplayer'; this.id = 'htmlvideoplayer'; // Let any players created by plugins take priority this.priority = 1; this._fetchQueue = 0; this.isFetching = false; } 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(instance, 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(); instance._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, 'object', 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(this, 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'); elemAudioTracks.forEach((audioTrack, i) => { 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 `