From e95fb681ac734dfda4f91ef10a69e9ad252b7340 Mon Sep 17 00:00:00 2001 From: Ian Walton Date: Wed, 7 Apr 2021 01:40:26 -0400 Subject: [PATCH] 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 000000000..ff04278b6 --- /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 000000000..9e8e963c1 --- /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 `