From 456f32ee15da6ad8dbab3795aae668b03fb62d31 Mon Sep 17 00:00:00 2001 From: Ian Walton Date: Wed, 7 Apr 2021 01:39:34 -0400 Subject: [PATCH 01/19] Support features for JellyfinMediaPlayer. --- src/components/playback/playbackmanager.js | 18 +++++++++++++----- src/components/syncPlay/core/PlaybackCore.js | 18 ++++++++++++------ src/components/syncPlay/core/QueueCore.js | 6 ++++-- .../syncPlay/ui/playbackPermissionManager.js | 10 ++++++++-- .../syncPlay/ui/players/HtmlVideoPlayer.js | 10 ++++++++++ src/config.json | 1 + src/controllers/playback/video/index.js | 4 ++-- src/scripts/libraryMenu.js | 8 +++++++- src/scripts/mouseManager.js | 4 ++-- src/scripts/settings/webSettings.js | 9 +++++++++ 10 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index 2b6c13bb1c..0e2bd7c957 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -429,7 +429,7 @@ function getPlaybackInfo(player, enableDirectStream, allowVideoStreamCopy, allowAudioStreamCopy) { - if (!itemHelper.isLocalItem(item) && item.MediaType === 'Audio') { + if (!itemHelper.isLocalItem(item) && item.MediaType === 'Audio' && !player.useServerPlaybackInfoForAudio) { return Promise.resolve({ MediaSources: [ { @@ -1692,7 +1692,7 @@ class PlaybackManager { if (validatePlaybackInfoResult(self, result)) { currentMediaSource = result.MediaSources[0]; - const streamInfo = createStreamInfo(apiClient, currentItem.MediaType, currentItem, currentMediaSource, ticks); + const streamInfo = createStreamInfo(apiClient, currentItem.MediaType, currentItem, currentMediaSource, ticks, player); streamInfo.fullscreen = currentPlayOptions.fullscreen; streamInfo.lastMediaInfoQuery = lastMediaInfoQuery; @@ -2272,7 +2272,7 @@ class PlaybackManager { playOptions.items = null; return getPlaybackMediaSource(player, apiClient, deviceProfile, maxBitrate, item, startPosition, mediaSourceId, audioStreamIndex, subtitleStreamIndex).then(function (mediaSource) { - const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition); + const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player); streamInfo.fullscreen = playOptions.fullscreen; @@ -2311,7 +2311,7 @@ class PlaybackManager { return player.getDeviceProfile(item).then(function (deviceProfile) { return getPlaybackMediaSource(player, apiClient, deviceProfile, maxBitrate, item, startPosition, options.mediaSourceId, options.audioStreamIndex, options.subtitleStreamIndex).then(function (mediaSource) { - return createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition); + return createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player); }); }); }); @@ -2337,7 +2337,7 @@ class PlaybackManager { }); }; - function createStreamInfo(apiClient, type, item, mediaSource, startPosition) { + function createStreamInfo(apiClient, type, item, mediaSource, startPosition, player) { let mediaUrl; let contentType; let transcodingOffsetTicks = 0; @@ -2349,6 +2349,14 @@ class PlaybackManager { const mediaSourceContainer = (mediaSource.Container || '').toLowerCase(); let directOptions; + if (mediaSource.MediaStreams && player.useFullSubtitleUrls) { + mediaSource.MediaStreams.map(stream => { + if (stream.DeliveryUrl && stream.DeliveryUrl.startsWith('/')) { + stream.DeliveryUrl = apiClient.getUrl(stream.DeliveryUrl); + } + }); + } + if (type === 'Video' || type === 'Audio') { contentType = getMimeType(type.toLowerCase(), mediaSourceContainer); diff --git a/src/components/syncPlay/core/PlaybackCore.js b/src/components/syncPlay/core/PlaybackCore.js index 12e0c67abb..b88a1f0739 100644 --- a/src/components/syncPlay/core/PlaybackCore.js +++ b/src/components/syncPlay/core/PlaybackCore.js @@ -118,9 +118,11 @@ class PlaybackCore { * Sends a buffering request to the server. * @param {boolean} isBuffering Whether this client is buffering or not. */ - sendBufferingRequest(isBuffering = true) { + async sendBufferingRequest(isBuffering = true) { const playerWrapper = this.manager.getPlayerWrapper(); - const currentPosition = playerWrapper.currentTime(); + const currentPosition = (playerWrapper.currentTimeAsync + ? await playerWrapper.currentTimeAsync() + : playerWrapper.currentTime()); const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond); const isPlaying = playerWrapper.isPlaying(); @@ -155,7 +157,7 @@ class PlaybackCore { * Applies a command and checks the playback state if a duplicate command is received. * @param {Object} command The playback command. */ - applyCommand(command) { + async applyCommand(command) { // Check if duplicate. if (this.lastCommand && this.lastCommand.When.getTime() === command.When.getTime() && @@ -177,7 +179,9 @@ class PlaybackCore { } else { // Check if playback state matches requested command. const playerWrapper = this.manager.getPlayerWrapper(); - const currentPositionTicks = Math.round(playerWrapper.currentTime() * Helper.TicksPerMillisecond); + const currentPositionTicks = Math.round((playerWrapper.currentTimeAsync + ? await playerWrapper.currentTimeAsync() + : playerWrapper.currentTime()) * Helper.TicksPerMillisecond); const isPlaying = playerWrapper.isPlaying(); switch (command.Command) { @@ -255,14 +259,16 @@ class PlaybackCore { * @param {Date} playAtTime The server's UTC time at which to resume playback. * @param {number} positionTicks The PositionTicks from where to resume. */ - scheduleUnpause(playAtTime, positionTicks) { + async scheduleUnpause(playAtTime, positionTicks) { this.clearScheduledCommand(); const enableSyncTimeout = this.maxDelaySpeedToSync / 2.0; const currentTime = new Date(); const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime); const playerWrapper = this.manager.getPlayerWrapper(); - const currentPositionTicks = playerWrapper.currentTime() * Helper.TicksPerMillisecond; + const currentPositionTicks = (playerWrapper.currentTimeAsync + ? await playerWrapper.currentTimeAsync() + : playerWrapper.currentTime()) * Helper.TicksPerMillisecond; if (playAtTimeLocal > currentTime) { const playTimeout = playAtTimeLocal - currentTime; diff --git a/src/components/syncPlay/core/QueueCore.js b/src/components/syncPlay/core/QueueCore.js index ba9bb754fe..1dcc765ff1 100644 --- a/src/components/syncPlay/core/QueueCore.js +++ b/src/components/syncPlay/core/QueueCore.js @@ -165,14 +165,16 @@ class QueueCore { * @param {string} origin The origin of the wait call, used for debug. */ scheduleReadyRequestOnPlaybackStart(apiClient, origin) { - Helper.waitForEventOnce(this.manager, 'playbackstart', Helper.WaitForEventDefaultTimeout, ['playbackerror']).then(() => { + Helper.waitForEventOnce(this.manager, 'playbackstart', Helper.WaitForEventDefaultTimeout, ['playbackerror']).then(async () => { console.debug('SyncPlay scheduleReadyRequestOnPlaybackStart: local pause and notify server.'); const playerWrapper = this.manager.getPlayerWrapper(); playerWrapper.localPause(); const currentTime = new Date(); const now = this.manager.timeSyncCore.localDateToRemote(currentTime); - const currentPosition = playerWrapper.currentTime(); + const currentPosition = (playerWrapper.currentTimeAsync + ? await playerWrapper.currentTimeAsync() + : playerWrapper.currentTime()); const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond); const isPlaying = playerWrapper.isPlaying(); diff --git a/src/components/syncPlay/ui/playbackPermissionManager.js b/src/components/syncPlay/ui/playbackPermissionManager.js index e2d7ef2f4c..feebf85876 100644 --- a/src/components/syncPlay/ui/playbackPermissionManager.js +++ b/src/components/syncPlay/ui/playbackPermissionManager.js @@ -1,3 +1,5 @@ +import { getIgnorePlayPermission } from '../../../scripts/settings/webSettings'; + /** * Creates an audio element that plays a silent sound. * @returns {HTMLMediaElement} The audio element. @@ -32,8 +34,12 @@ class PlaybackPermissionManager { * Tests playback permission. Grabs the permission when called inside a click event (or any other valid user interaction). * @returns {Promise} Promise that resolves succesfully if playback permission is allowed. */ - check () { - return new Promise((resolve, reject) => { + async check () { + if (await getIgnorePlayPermission()) { + return true; + } + + return await new Promise((resolve, reject) => { const media = createTestMediaElement(); media.play().then(() => { resolve(); diff --git a/src/components/syncPlay/ui/players/HtmlVideoPlayer.js b/src/components/syncPlay/ui/players/HtmlVideoPlayer.js index cc045d4954..16d26cd1c2 100644 --- a/src/components/syncPlay/ui/players/HtmlVideoPlayer.js +++ b/src/components/syncPlay/ui/players/HtmlVideoPlayer.js @@ -17,6 +17,16 @@ class HtmlVideoPlayer extends NoActivePlayer { this.isPlayerActive = false; this.savedPlaybackRate = 1.0; this.minBufferingThresholdMillis = 3000; + + if (player.currentTimeAsync) { + /** + * Gets current playback position. + * @returns {Promise} The player position, in milliseconds. + */ + this.currentTimeAsync = () => { + return this.player.currentTimeAsync(); + }; + } } /** diff --git a/src/config.json b/src/config.json index 9dd6fa01d6..e511dcdee6 100644 --- a/src/config.json +++ b/src/config.json @@ -1,6 +1,7 @@ { "includeCorsCredentials": false, "multiserver": false, + "ignorePlayPermission": false, "themes": [ { "name": "Apple TV", diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js index d74bc103e7..0b531954c0 100644 --- a/src/controllers/playback/video/index.js +++ b/src/controllers/playback/video/index.js @@ -312,8 +312,8 @@ import { appRouter } from '../../../components/appRouter'; function onPointerMove(e) { if ((e.pointerType || (layoutManager.mobile ? 'touch' : 'mouse')) === 'mouse') { - const eventX = e.screenX || 0; - const eventY = e.screenY || 0; + const eventX = e.screenX || e.clientX || 0; + const eventY = e.screenY || e.clientY || 0; const obj = lastPointerMoveData; if (!obj) { diff --git a/src/scripts/libraryMenu.js b/src/scripts/libraryMenu.js index c9ad29ddff..7e6c539442 100644 --- a/src/scripts/libraryMenu.js +++ b/src/scripts/libraryMenu.js @@ -76,7 +76,13 @@ import Headroom from 'headroom.js'; } function onBackClick() { - appRouter.back(); + // If playing on a player that can't be destroyed with navigation, stop it manually. + const player = playbackManager.getCurrentPlayer(); + if (player && player.mustDestroy && skinHeader.classList.contains('osdHeader')) { + playbackManager.stop(); + } else { + appRouter.back(); + } } function retranslateUi() { diff --git a/src/scripts/mouseManager.js b/src/scripts/mouseManager.js index 3f37dfbd9e..1ad3389c04 100644 --- a/src/scripts/mouseManager.js +++ b/src/scripts/mouseManager.js @@ -54,8 +54,8 @@ import dom from '../scripts/dom'; let lastPointerMoveData; function onPointerMove(e) { - const eventX = e.screenX; - const eventY = e.screenY; + const eventX = e.screenX || e.clientX; + const eventY = e.screenY || e.clientY; // if coord don't exist how could it move if (typeof eventX === 'undefined' && typeof eventY === 'undefined') { diff --git a/src/scripts/settings/webSettings.js b/src/scripts/settings/webSettings.js index e88e65ad8f..f985cdb118 100644 --- a/src/scripts/settings/webSettings.js +++ b/src/scripts/settings/webSettings.js @@ -79,6 +79,15 @@ export function getMultiServer() { }); } +export function getIgnorePlayPermission() { + return getConfig().then(config => { + return !!config.ignorePlayPermission; + }).catch(error => { + console.log('cannot get web config:', error); + return false; + }); +} + export function getServers() { return getConfig().then(config => { return config.servers || []; From e95fb681ac734dfda4f91ef10a69e9ad252b7340 Mon Sep 17 00:00:00 2001 From: Ian Walton Date: Wed, 7 Apr 2021 01:40:26 -0400 Subject: [PATCH 02/19] MPV player plugins for JellyfinMediaPlayer. --- src/plugins/mpvAudioPlayer/plugin.js | 378 +++++++++++++ src/plugins/mpvVideoPlayer/plugin.js | 776 +++++++++++++++++++++++++++ src/plugins/mpvVideoPlayer/style.css | 79 +++ 3 files changed, 1233 insertions(+) create mode 100644 src/plugins/mpvAudioPlayer/plugin.js create mode 100644 src/plugins/mpvVideoPlayer/plugin.js create mode 100644 src/plugins/mpvVideoPlayer/style.css diff --git a/src/plugins/mpvAudioPlayer/plugin.js b/src/plugins/mpvAudioPlayer/plugin.js new file mode 100644 index 0000000000..ff04278b65 --- /dev/null +++ b/src/plugins/mpvAudioPlayer/plugin.js @@ -0,0 +1,378 @@ +import { Events } from 'jellyfin-apiclient'; +import * as htmlMediaHelper from '../../components/htmlMediaHelper'; + +function loadScript(src) { + return new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = src; + s.onload = resolve; + s.onerror = reject; + document.head.appendChild(s); + }); +} + +async function createApi() { + await loadScript('qrc:///qtwebchannel/qwebchannel.js'); + const channel = await new Promise((resolve) => { + /*global QWebChannel */ + new QWebChannel(window.qt.webChannelTransport, resolve); + }); + return channel.objects; +} + +async function getApi() { + if (window.apiPromise) { + return await window.apiPromise; + } + + window.apiPromise = createApi(); + return await window.apiPromise; +} + +let fadeTimeout; +function fade(instance, elem, startingVolume) { + instance._isFadingOut = true; + + // Need to record the starting volume on each pass rather than querying elem.volume + // This is due to iOS safari not allowing volume changes and always returning the system volume value + const newVolume = Math.max(0, startingVolume - 15); + console.debug('fading volume to ' + newVolume); + instance.api.player.setVolume(newVolume); + + if (newVolume <= 0) { + instance._isFadingOut = false; + return Promise.resolve(); + } + + return new Promise(function (resolve, reject) { + cancelFadeTimeout(); + fadeTimeout = setTimeout(function () { + fade(instance, null, newVolume).then(resolve, reject); + }, 100); + }); +} + +function cancelFadeTimeout() { + const timeout = fadeTimeout; + if (timeout) { + clearTimeout(timeout); + fadeTimeout = null; + } +} + +class HtmlAudioPlayer { + constructor() { + const self = this; + + self.name = 'Html Audio Player'; + self.type = 'mediaplayer'; + self.id = 'htmlaudioplayer'; + self.useServerPlaybackInfoForAudio = true; + self.mustDestroy = true; + + self._duration = undefined; + self._currentTime = undefined; + self._paused = false; + self._volume = htmlMediaHelper.getSavedVolume() * 100; + self._playRate = 1; + + self.api = undefined; + + self.ensureApi = async () => { + if (!self.api) { + self.api = await getApi(); + } + }; + + self.play = async (options) => { + self._started = false; + self._timeUpdated = false; + self._currentTime = null; + self._duration = undefined; + + await self.ensureApi(); + const player = self.api.player; + player.playing.connect(onPlaying); + player.positionUpdate.connect(onTimeUpdate); + player.finished.connect(onEnded); + player.updateDuration.connect(onDuration); + player.error.connect(onError); + player.paused.connect(onPause); + + return await setCurrentSrc(options); + }; + + function setCurrentSrc(options) { + return new Promise((resolve) => { + const val = options.url; + self._currentSrc = val; + console.debug('playing url: ' + val); + + // Convert to seconds + const ms = (options.playerStartPositionTicks || 0) / 10000; + self._currentPlayOptions = options; + + self.api.player.load(val, + { startMilliseconds: ms, autoplay: true }, + {type: 'music', headers: {'User-Agent': 'JellyfinMediaPlayer'}, frameRate: 0, media: {}}, + '#1', + '', + resolve); + }); + } + + self.onEndedInternal = () => { + const stopInfo = { + src: self._currentSrc + }; + + Events.trigger(self, 'stopped', [stopInfo]); + + self._currentTime = null; + self._currentSrc = null; + self._currentPlayOptions = null; + }; + + self.stop = async (destroyPlayer) => { + cancelFadeTimeout(); + + const src = self._currentSrc; + + if (src) { + const originalVolume = self._volume; + + await self.ensureApi(); + return await fade(self, null, self._volume).then(function () { + self.pause(); + self.setVolume(originalVolume, false); + + self.onEndedInternal(); + + if (destroyPlayer) { + self.destroy(); + } + }); + } + return; + }; + + self.destroy = async () => { + await self.ensureApi(); + self.api.player.stop(); + + const player = self.api.player; + player.playing.disconnect(onPlaying); + player.positionUpdate.disconnect(onTimeUpdate); + player.finished.disconnect(onEnded); + self._duration = undefined; + player.updateDuration.disconnect(onDuration); + player.error.disconnect(onError); + player.paused.disconnect(onPause); + }; + + function onDuration(duration) { + self._duration = duration; + } + + function onEnded() { + self.onEndedInternal(); + } + + function onTimeUpdate(time) { + // Don't trigger events after user stop + if (!self._isFadingOut) { + self._currentTime = time; + Events.trigger(self, 'timeupdate'); + } + } + + function onPlaying() { + if (!self._started) { + self._started = true; + } + + self.setPlaybackRate(1); + self.setMute(false); + + if (self._paused) { + self._paused = false; + Events.trigger(self, 'unpause'); + } + + Events.trigger(self, 'playing'); + } + + function onPause() { + self._paused = true; + Events.trigger(self, 'pause'); + } + + function onError(error) { + console.error(`media element error: ${error}`); + + htmlMediaHelper.onErrorInternal(self, 'mediadecodeerror'); + } + } + + currentSrc() { + return this._currentSrc; + } + + canPlayMediaType(mediaType) { + return (mediaType || '').toLowerCase() === 'audio'; + } + + getDeviceProfile() { + return Promise.resolve({ + 'Name': 'Jellyfin Media Player', + 'MusicStreamingTranscodingBitrate': 1280000, + 'TimelineOffsetSeconds': 5, + 'TranscodingProfiles': [ + {'Type': 'Audio'} + ], + 'DirectPlayProfiles': [{'Type': 'Audio'}], + 'ResponseProfiles': [], + 'ContainerProfiles': [], + 'CodecProfiles': [], + 'SubtitleProfiles': [] + }); + } + + currentTime(val) { + if (val != null) { + this.ensureApi().then(() => { + this.api.player.seekTo(val); + }); + return; + } + + return this._currentTime; + } + + async currentTimeAsync() { + await this.ensureApi(); + return await new Promise((resolve) => { + this.api.player.getPosition(resolve); + }); + } + + duration() { + if (this._duration) { + return this._duration; + } + + return null; + } + + seekable() { + return Boolean(this._duration); + } + + getBufferedRanges() { + return []; + } + + async pause() { + await this.ensureApi(); + this.api.player.pause(); + } + + // This is a retry after error + async resume() { + await this.ensureApi(); + this._paused = false; + this.api.player.play(); + } + + async unpause() { + await this.ensureApi(); + this.api.player.play(); + } + + paused() { + return this._paused; + } + + async setPlaybackRate(value) { + this._playRate = value; + await this.ensureApi(); + this.api.player.setPlaybackRate(value * 1000); + } + + getPlaybackRate() { + return this._playRate; + } + + getSupportedPlaybackRates() { + return [{ + name: '0.5x', + id: 0.5 + }, { + name: '0.75x', + id: 0.75 + }, { + name: '1x', + id: 1.0 + }, { + name: '1.25x', + id: 1.25 + }, { + name: '1.5x', + id: 1.5 + }, { + name: '1.75x', + id: 1.75 + }, { + name: '2x', + id: 2.0 + }]; + } + + async setVolume(val, save = true) { + this._volume = val; + if (save) { + htmlMediaHelper.saveVolume((val || 100) / 100); + Events.trigger(this, 'volumechange'); + } + await this.ensureApi(); + this.api.player.setVolume(val); + } + + getVolume() { + return this._volume; + } + + volumeUp() { + this.setVolume(Math.min(this.getVolume() + 2, 100)); + } + + volumeDown() { + this.setVolume(Math.max(this.getVolume() - 2, 0)); + } + + async setMute(mute) { + this._muted = mute; + await this.ensureApi(); + this.api.player.setMuted(mute); + } + + isMuted() { + return this._muted; + } + + supports(feature) { + if (!supportedFeatures) { + supportedFeatures = getSupportedFeatures(); + } + + return supportedFeatures.indexOf(feature) !== -1; + } +} + +let supportedFeatures; + +function getSupportedFeatures() { + return ['PlaybackRate']; +} + +export default HtmlAudioPlayer; diff --git a/src/plugins/mpvVideoPlayer/plugin.js b/src/plugins/mpvVideoPlayer/plugin.js new file mode 100644 index 0000000000..9e8e963c13 --- /dev/null +++ b/src/plugins/mpvVideoPlayer/plugin.js @@ -0,0 +1,776 @@ +import browser from '../../scripts/browser'; +import { Events } from 'jellyfin-apiclient'; +import loading from '../../components/loading/loading'; +import { appRouter } from '../../components/appRouter'; +import { + saveVolume, + getSavedVolume, + onErrorInternal +} from '../../components/htmlMediaHelper'; +import Screenfull from 'screenfull'; +import globalize from '../../scripts/globalize'; + +function loadScript(src) { + return new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = src; + s.onload = resolve; + s.onerror = reject; + document.head.appendChild(s); + }); +} + +async function createApi() { + await loadScript('qrc:///qtwebchannel/qwebchannel.js'); + const channel = await new Promise((resolve) => { + /*global QWebChannel */ + new QWebChannel(window.qt.webChannelTransport, resolve); + }); + return channel.objects; +} + +async function getApi() { + if (window.apiPromise) { + return await window.apiPromise; + } + + window.apiPromise = createApi(); + return await window.apiPromise; +} + +/* eslint-disable indent */ + + function getMediaStreamAudioTracks(mediaSource) { + return mediaSource.MediaStreams.filter(function (s) { + return s.Type === 'Audio'; + }); + } + + export class HtmlVideoPlayer { + /** + * @type {string} + */ + name; + /** + * @type {string} + */ + type = 'mediaplayer'; + /** + * @type {string} + */ + id = 'htmlvideoplayer'; + useFullSubtitleUrls = true; + mustDestroy = true; + /** + * @type {boolean} + */ + isFetching = false; + + /** + * @type {HTMLDivElement | null | undefined} + */ + #videoDialog; + /** + * @type {number | undefined} + */ + #subtitleTrackIndexToSetOnPlaying; + /** + * @type {number | null} + */ + #audioTrackIndexToSetOnPlaying; + /** + * @type {boolean | undefined} + */ + #showTrackOffset; + /** + * @type {number | undefined} + */ + #currentTrackOffset; + /** + * @type {string[] | undefined} + */ + #supportedFeatures; + /** + * @type {string | undefined} + */ + #currentSrc; + /** + * @type {boolean | undefined} + */ + #started; + /** + * @type {boolean | undefined} + */ + #timeUpdated; + /** + * @type {number | null | undefined} + */ + #currentTime; + /** + * @private (used in other files) + * @type {any | undefined} + */ + _currentPlayOptions; + /** + * @type {any | undefined} + */ + #lastProfile; + /** + * @type {number | undefined} + */ + #duration; + /** + * @type {boolean} + */ + #paused = false; + /** + * @type {int} + */ + #volume = 100; + /** + * @type {boolean} + */ + #muted = false; + /** + * @type {float} + */ + #playRate = 1; + #api = undefined; + + constructor() { + if (browser.edgeUwp) { + this.name = 'Windows Video Player'; + } else { + this.name = 'Html Video Player'; + } + } + + currentSrc() { + return this.#currentSrc; + } + + async ensureApi() { + if (!this.#api) { + this.#api = await getApi(); + } + } + + async play(options) { + this.#started = false; + this.#timeUpdated = false; + this.#currentTime = null; + + this.resetSubtitleOffset(); + loading.show(); + await this.ensureApi(); + this.#api.power.setScreensaverEnabled(false); + const elem = await this.createMediaElement(options); + return await this.setCurrentSrc(elem, options); + } + + /** + * @private + */ + getSubtitleParam() { + const options = this._currentPlayOptions; + + if (this.#subtitleTrackIndexToSetOnPlaying != null && this.#subtitleTrackIndexToSetOnPlaying >= 0) { + const initialSubtitleStream = options.mediaSource.MediaStreams[this.#subtitleTrackIndexToSetOnPlaying]; + if (!initialSubtitleStream || initialSubtitleStream.DeliveryMethod === 'Encode') { + this.#subtitleTrackIndexToSetOnPlaying = -1; + } else if (initialSubtitleStream.DeliveryMethod === 'External') { + return '#,' + initialSubtitleStream.DeliveryUrl; + } + } + + if (this.#subtitleTrackIndexToSetOnPlaying == -1 || this.#subtitleTrackIndexToSetOnPlaying == null) { + return ''; + } + + return '#' + this.#subtitleTrackIndexToSetOnPlaying; + } + + /** + * @private + */ + setCurrentSrc(elem, options) { + return new Promise((resolve) => { + const val = options.url; + console.debug(`playing url: ${val}`); + + // Convert to seconds + const ms = (options.playerStartPositionTicks || 0) / 10000; + this._currentPlayOptions = options; + this.#subtitleTrackIndexToSetOnPlaying = options.mediaSource.DefaultSubtitleStreamIndex == null ? -1 : options.mediaSource.DefaultSubtitleStreamIndex; + this.#audioTrackIndexToSetOnPlaying = options.playMethod === 'Transcode' ? null : options.mediaSource.DefaultAudioStreamIndex; + + const player = this.#api.player; + player.load(val, + { startMilliseconds: ms, autoplay: true }, + {type: 'video', headers: {'User-Agent': 'JellyfinMediaPlayer'}, frameRate: 0, media: {}}, + (this.#audioTrackIndexToSetOnPlaying != null) + ? '#' + this.#audioTrackIndexToSetOnPlaying : '#1', + this.getSubtitleParam(), + resolve); + }); + } + + async setSubtitleStreamIndex(index) { + await this.ensureApi(); + this.#subtitleTrackIndexToSetOnPlaying = index; + this.#api.player.setSubtitleStream(this.getSubtitleParam()); + } + + async resetSubtitleOffset() { + await this.ensureApi(); + this.#currentTrackOffset = 0; + this.#showTrackOffset = false; + this.#api.player.setSubtitleDelay(0); + } + + enableShowingSubtitleOffset() { + this.#showTrackOffset = true; + } + + disableShowingSubtitleOffset() { + this.#showTrackOffset = false; + } + + isShowingSubtitleOffsetEnabled() { + return this.#showTrackOffset; + } + + async setSubtitleOffset(offset) { + await this.ensureApi(); + const offsetValue = parseFloat(offset); + this.#currentTrackOffset = offsetValue; + this.#api.player.setSubtitleDelay(offset); + } + + getSubtitleOffset() { + return this.#currentTrackOffset; + } + + /** + * @private + */ + isAudioStreamSupported() { + return true; + } + + /** + * @private + */ + getSupportedAudioStreams() { + const profile = this.#lastProfile; + + return getMediaStreamAudioTracks(this._currentPlayOptions.mediaSource).filter((stream) => { + return this.isAudioStreamSupported(stream, profile); + }); + } + + async setAudioStreamIndex(index) { + await this.ensureApi(); + this.#audioTrackIndexToSetOnPlaying = 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; + } + + this.#api.player.setAudioStream(index != -1 ? '#' + index : ''); + } + + onEndedInternal() { + const stopInfo = { + src: this._currentSrc + }; + + Events.trigger(this, 'stopped', [stopInfo]); + + this._currentTime = null; + this._currentSrc = null; + this._currentPlayOptions = null; + } + + async stop(destroyPlayer) { + await this.ensureApi(); + this.#api.player.stop(); + this.#api.power.setScreensaverEnabled(true); + + this.onEndedInternal(); + + if (destroyPlayer) { + this.destroy(); + } + return; + } + + async destroy() { + await this.ensureApi(); + this.#api.player.stop(); + this.#api.power.setScreensaverEnabled(true); + + appRouter.setTransparency('none'); + document.body.classList.remove('hide-scroll'); + + const player = this.#api.player; + player.playing.disconnect(this.onPlaying); + player.positionUpdate.disconnect(this.onTimeUpdate); + player.finished.disconnect(this.onEnded); + this.#duration = undefined; + player.updateDuration.disconnect(this.onDuration); + player.error.disconnect(this.onError); + player.paused.disconnect(this.onPause); + + 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 + */ + onEnded = () => { + this.onEndedInternal(); + }; + + /** + * @private + */ + onTimeUpdate = (time) => { + if (time && !this.#timeUpdated) { + this.#timeUpdated = true; + } + + this.#currentTime = time; + Events.trigger(this, 'timeupdate'); + }; + + /** + * @private + */ + onNavigatedToOsd = () => { + const dlg = this.#videoDialog; + if (dlg) { + dlg.classList.remove('videoPlayerContainer-onTop'); + } + }; + + /** + * @private + */ + onPlaying = () => { + if (!this.#started) { + this.#started = true; + + loading.hide(); + + const volume = getSavedVolume() * 100; + if (volume != this.#volume) { + this.setVolume(volume, false); + } + + this.setPlaybackRate(1); + this.setMute(false); + + if (this._currentPlayOptions.fullscreen) { + appRouter.showVideoOsd().then(this.onNavigatedToOsd); + } else { + appRouter.setTransparency('backdrop'); + this.#videoDialog.classList.remove('videoPlayerContainer-onTop'); + } + } + + if (this.#paused) { + this.#paused = false; + Events.trigger(this, 'unpause'); + } + + Events.trigger(this, 'playing'); + }; + + /** + * @private + */ + onPause = () => { + this.#paused = true; + // For Syncplay ready notification + Events.trigger(this, 'pause'); + }; + + onWaiting = () => { + Events.trigger(this, 'waiting'); + }; + + /** + * @private + * @param e {Event} The event received from the `