From 31e9c3d95e85f8c5e3d11c3ce401f2a35e1d62fa Mon Sep 17 00:00:00 2001 From: Ionut Andrei Oanca Date: Sun, 24 Jan 2021 01:02:10 +0100 Subject: [PATCH 001/453] Clear playlist in SyncPlay group --- src/components/syncPlay/core/Controller.js | 12 ++++++++++++ src/components/syncPlay/ui/players/NoActivePlayer.js | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/components/syncPlay/core/Controller.js b/src/components/syncPlay/core/Controller.js index 695ccde9f1..df0dbd177a 100644 --- a/src/components/syncPlay/core/Controller.js +++ b/src/components/syncPlay/core/Controller.js @@ -100,6 +100,18 @@ class Controller { }); } + /** + * Clears the playlist of a SyncPlay group. + * @param {Array} clearPlayingItem Whether to remove the playing item as well. + */ + clearPlaylist(clearPlayingItem = false) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayRemoveFromPlaylist({ + ClearPlaylist: true, + ClearPlayingItem: clearPlayingItem + }); + } + /** * Removes items from SyncPlay group playlist. * @param {Array} playlistItemIds The items to remove. diff --git a/src/components/syncPlay/ui/players/NoActivePlayer.js b/src/components/syncPlay/ui/players/NoActivePlayer.js index 48dfe953ba..92dced3ef4 100644 --- a/src/components/syncPlay/ui/players/NoActivePlayer.js +++ b/src/components/syncPlay/ui/players/NoActivePlayer.js @@ -45,6 +45,7 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer { playbackManager._localPlay = playbackManager.play; playbackManager._localSetCurrentPlaylistItem = playbackManager.setCurrentPlaylistItem; + playbackManager._localClearQueue = playbackManager.clearQueue; playbackManager._localRemoveFromPlaylist = playbackManager.removeFromPlaylist; playbackManager._localMovePlaylistItem = playbackManager.movePlaylistItem; playbackManager._localQueue = playbackManager.queue; @@ -62,6 +63,7 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer { playbackManager.play = this.playRequest; playbackManager.setCurrentPlaylistItem = this.setCurrentPlaylistItemRequest; + playbackManager.clearQueue = this.clearQueueRequest; playbackManager.removeFromPlaylist = this.removeFromPlaylistRequest; playbackManager.movePlaylistItem = this.movePlaylistItemRequest; playbackManager.queue = this.queueRequest; @@ -93,6 +95,7 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer { playbackManager.play = playbackManager._localPlay; playbackManager.setCurrentPlaylistItem = playbackManager._localSetCurrentPlaylistItem; + playbackManager.clearQueue = this._localClearQueue; playbackManager.removeFromPlaylist = playbackManager._localRemoveFromPlaylist; playbackManager.movePlaylistItem = playbackManager._localMovePlaylistItem; playbackManager.queue = playbackManager._localQueue; @@ -247,6 +250,14 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer { controller.setCurrentPlaylistItem(playlistItemId); } + /** + * Overrides PlaybackManager's clearQueue method. + */ + clearQueueRequest(clearPlayingItem, player) { + const controller = syncPlayManager.getController(); + controller.clearPlaylist(clearPlayingItem); + } + /** * Overrides PlaybackManager's removeFromPlaylist method. */ From c6a245fa6a0b3189ca8fdd9185ea29da262cb1d9 Mon Sep 17 00:00:00 2001 From: Ionut Andrei Oanca Date: Sun, 24 Jan 2021 01:26:24 +0100 Subject: [PATCH 002/453] Fix queue forcing unique items in SyncPlay --- src/components/syncPlay/core/Helper.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/syncPlay/core/Helper.js b/src/components/syncPlay/core/Helper.js index 53b47c47dd..3ecb44facb 100644 --- a/src/components/syncPlay/core/Helper.js +++ b/src/components/syncPlay/core/Helper.js @@ -91,8 +91,7 @@ export function getItemsForPlayback(apiClient, query) { return apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(function (item) { return { - Items: [item], - TotalRecordCount: 1 + Items: [item] }; }); } else { From 031e1c0f2681af65f824889f4493a857eb0d9bd0 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 28 Mar 2021 19:21:11 -0400 Subject: [PATCH 003/453] Improve behaviour of space bar pausing/playing video --- src/controllers/playback/video/index.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js index d74bc103e7..0834f492af 100644 --- a/src/controllers/playback/video/index.js +++ b/src/controllers/playback/video/index.js @@ -992,9 +992,13 @@ import { appRouter } from '../../../components/appRouter'; const key = keyboardnavigation.getKeyName(e); const isKeyModified = e.ctrlKey || e.altKey || e.metaKey; + const currentElement = document.activeElement; - if (!currentVisibleMenu && e.keyCode === 32) { - playbackManager.playPause(currentPlayer); + if (e.keyCode === 32) { + if (!currentElement.className.split(' ').includes('btnPause')) { + // If the focused button is the pause button it will already play/pause it + playbackManager.playPause(currentPlayer); + } showOsd(); return; } From bd170633c55231600679e215eeb2e31352e2c62f Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo Date: Mon, 29 Mar 2021 15:05:31 +0300 Subject: [PATCH 004/453] Fix pause --- src/controllers/playback/video/index.js | 30 +++++++++++++++++++------ 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js index 0834f492af..0484b656cc 100644 --- a/src/controllers/playback/video/index.js +++ b/src/controllers/playback/video/index.js @@ -1,5 +1,6 @@ import { playbackManager } from '../../../components/playback/playbackmanager'; import SyncPlay from '../../../components/syncPlay/core'; +import browser from '../../../scripts/browser'; import dom from '../../../scripts/dom'; import inputManager from '../../../scripts/inputManager'; import mouseManager from '../../../scripts/mouseManager'; @@ -987,17 +988,29 @@ import { appRouter } from '../../../components/appRouter'; */ let clickedElement; + function onClickCapture(e) { + // Firefox/Edge emits `click` even if `preventDefault` was used on `keydown` + // Ignore 'click' if another element was originally clicked + if (!e.target.contains(clickedElement)) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + } + function onKeyDown(e) { clickedElement = e.target; const key = keyboardnavigation.getKeyName(e); const isKeyModified = e.ctrlKey || e.altKey || e.metaKey; - const currentElement = document.activeElement; if (e.keyCode === 32) { - if (!currentElement.className.split(' ').includes('btnPause')) { - // If the focused button is the pause button it will already play/pause it + if (e.target.tagName !== 'BUTTON' || !layoutManager.tv) { playbackManager.playPause(currentPlayer); + e.preventDefault(); + e.stopPropagation(); + // Trick Firefox with a null element to skip next click + clickedElement = null; } showOsd(); return; @@ -1308,6 +1321,9 @@ import { appRouter } from '../../../components/appRouter'; capture: true, passive: true }); + if (browser.firefox || browser.edge) { + dom.addEventListener(document, 'click', onClickCapture, { capture: true }); + } } catch (e) { appRouter.goHome(); } @@ -1346,6 +1362,9 @@ import { appRouter } from '../../../components/appRouter'; capture: true, passive: true }); + if (browser.firefox || browser.edge) { + dom.removeEventListener(document, 'click', onClickCapture, { capture: true }); + } stopOsdHideTimer(); headerElement.classList.remove('osdHeader'); headerElement.classList.remove('osdHeader-hidden'); @@ -1495,10 +1514,7 @@ import { appRouter } from '../../../components/appRouter'; playbackManager.previousTrack(currentPlayer); }); view.querySelector('.btnPause').addEventListener('click', function () { - // Ignore 'click' if another element was originally clicked (Firefox/Edge issue) - if (this.contains(clickedElement)) { - playbackManager.playPause(currentPlayer); - } + playbackManager.playPause(currentPlayer); }); view.querySelector('.btnNextTrack').addEventListener('click', function () { playbackManager.nextTrack(currentPlayer); From 456f32ee15da6ad8dbab3795aae668b03fb62d31 Mon Sep 17 00:00:00 2001 From: Ian Walton Date: Wed, 7 Apr 2021 01:39:34 -0400 Subject: [PATCH 005/453] 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 006/453] 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 `