Bläddra i källkod

Add NativeShell support.

Ian Walton 3 år sedan
förälder
incheckning
8847224fba

+ 337 - 0
native/mpvAudioPlayer.js

@@ -0,0 +1,337 @@
+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);
+    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 mpvAudioPlayer {
+    constructor({ events, appHost, appSettings }) {
+        const self = this;
+
+        self.events = events;
+        self.appHost = appHost;
+        self.appSettings = appSettings;
+
+        self.name = 'MPV Audio Player';
+        self.type = 'mediaplayer';
+        self.id = 'mpvaudioplayer';
+        self.syncPlayWrapAs = 'htmlaudioplayer';
+        self.useServerPlaybackInfoForAudio = true;
+
+        self._duration = undefined;
+        self._currentTime = undefined;
+        self._paused = false;
+        self._volume = this.getSavedVolume() * 100;
+        self._playRate = 1;
+
+        self.play = (options) => {
+            self._started = false;
+            self._timeUpdated = false;
+            self._currentTime = null;
+            self._duration = undefined;
+
+            const player = window.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 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;
+
+                window.api.player.load(val,
+                    { startMilliseconds: ms, autoplay: true },
+                    {type: 'music', headers: {'User-Agent': 'JellyfinMediaPlayer'}, media: {}},
+                    '#1',
+                    '',
+                    resolve);
+            });
+        }
+
+        self.onEndedInternal = () => {
+            const stopInfo = {
+                src: self._currentSrc
+            };
+
+            self.events.trigger(self, 'stopped', [stopInfo]);
+
+            self._currentTime = null;
+            self._currentSrc = null;
+            self._currentPlayOptions = null;
+        };
+
+        self.stop = (destroyPlayer) => {
+            cancelFadeTimeout();
+
+            const src = self._currentSrc;
+
+            if (src) {
+                const originalVolume = self._volume;
+
+                return fade(self, null, self._volume).then(function () {
+                    self.pause();
+                    self.setVolume(originalVolume, false);
+
+                    self.onEndedInternal();
+
+                    if (destroyPlayer) {
+                        self.destroy();
+                    }
+                });
+            }
+            return Promise.resolve();
+        };
+
+        self.destroy = () => {
+            window.api.player.stop();
+
+            const player = window.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;
+                self.events.trigger(self, 'timeupdate');
+            }
+        }
+
+        function onPlaying() {
+            if (!self._started) {
+                self._started = true;
+            }
+
+            self.setPlaybackRate(1);
+            self.setMute(false);
+
+            if (self._paused) {
+                self._paused = false;
+                self.events.trigger(self, 'unpause');
+            }
+
+            self.events.trigger(self, 'playing');
+        }
+
+        function onPause() {
+            self._paused = true;
+            self.events.trigger(self, 'pause');
+        }
+
+        function onError(error) {
+            console.error(`media element error: ${error}`);
+
+            self.events.trigger(self, 'error', [
+                {
+                    type: 'mediadecodeerror'
+                }
+            ]);
+        }
+    }
+
+    getSavedVolume() {
+        return this.appSettings.get('volume') || 1;
+    }
+
+    currentSrc() {
+        return this._currentSrc;
+    }
+
+    canPlayMediaType(mediaType) {
+        return (mediaType || '').toLowerCase() === 'audio';
+    }
+
+    getDeviceProfile(item, options) {
+        if (this.appHost.getDeviceProfile) {
+            return this.appHost.getDeviceProfile(item, options);
+        }
+
+        return {};
+    }
+
+    currentTime(val) {
+        if (val != null) {
+            window.api.player.seekTo(val);
+            return;
+        }
+
+        return this._currentTime;
+    }
+
+    currentTimeAsync() {
+        return new Promise((resolve) => {
+            window.api.player.getPosition(resolve);
+        });
+    }
+
+    duration() {
+        if (this._duration) {
+            return this._duration;
+        }
+
+        return null;
+    }
+
+    seekable() {
+        return Boolean(this._duration);
+    }
+
+    getBufferedRanges() {
+        return [];
+    }
+
+    pause() {
+        window.api.player.pause();
+    }
+
+    // This is a retry after error
+    resume() {
+        this._paused = false;
+        window.api.player.play();
+    }
+
+    unpause() {
+        window.api.player.play();
+    }
+
+    paused() {
+        return this._paused;
+    }
+
+    setPlaybackRate(value) {
+        this._playRate = value;
+        window.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
+        }];
+    }
+
+    saveVolume(value) {
+        if (value) {
+            this.appSettings.set('volume', value);
+        }
+    }
+
+    setVolume(val, save = true) {
+        this._volume = val;
+        if (save) {
+            this.saveVolume((val || 100) / 100);
+            this.events.trigger(this, 'volumechange');
+        }
+        window.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));
+    }
+
+    setMute(mute) {
+        this._muted = mute;
+        window.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'];
+}
+
+window._mpvAudioPlayer = mpvAudioPlayer;

+ 721 - 0
native/mpvVideoPlayer.js

@@ -0,0 +1,721 @@
+/* eslint-disable indent */
+
+    function getMediaStreamAudioTracks(mediaSource) {
+        return mediaSource.MediaStreams.filter(function (s) {
+            return s.Type === 'Audio';
+        });
+    }
+
+    class mpvVideoPlayer {
+        constructor({ events, loading, appRouter, globalize, appHost, appSettings }) {
+            this.events = events;
+            this.loading = loading;
+            this.appRouter = appRouter;
+            this.globalize = globalize;
+            this.appHost = appHost;
+            this.appSettings = appSettings;
+
+            /**
+             * @type {string}
+             */
+            this.name = 'MPV Video Player';
+            /**
+             * @type {string}
+             */
+            this.type = 'mediaplayer';
+            /**
+             * @type {string}
+             */
+            this.id = 'mpvvideoplayer';
+            this.syncPlayWrapAs = 'htmlvideoplayer';
+            this.priority = -1;
+            this.useFullSubtitleUrls = true;
+            /**
+             * @type {boolean}
+             */
+            this.isFetching = false;
+    
+            /**
+             * @type {HTMLDivElement | null | undefined}
+             */
+            this._videoDialog = undefined;
+            /**
+             * @type {number | undefined}
+             */
+            this._subtitleTrackIndexToSetOnPlaying = undefined;
+            /**
+             * @type {number | null}
+             */
+            this._audioTrackIndexToSetOnPlaying = undefined;
+            /**
+             * @type {boolean | undefined}
+             */
+            this._showTrackOffset = undefined;
+            /**
+             * @type {number | undefined}
+             */
+            this._currentTrackOffset = undefined;
+            /**
+             * @type {string[] | undefined}
+             */
+            this._supportedFeatures = undefined;
+            /**
+             * @type {string | undefined}
+             */
+            this._currentSrc = undefined;
+            /**
+             * @type {boolean | undefined}
+             */
+            this._started = undefined;
+            /**
+             * @type {boolean | undefined}
+             */
+            this._timeUpdated = undefined;
+            /**
+             * @type {number | null | undefined}
+             */
+            this._currentTime = undefined;
+            /**
+             * @private (used in other files)
+             * @type {any | undefined}
+             */
+            this._currentPlayOptions = undefined;
+            /**
+             * @type {any | undefined}
+             */
+            this._lastProfile = undefined;
+            /**
+             * @type {number | undefined}
+             */
+            this._duration = undefined;
+            /**
+             * @type {boolean}
+             */
+            this._paused = false;
+            /**
+             * @type {int}
+             */
+            this._volume = 100;
+            /**
+             * @type {boolean}
+             */
+            this._muted = false;
+            /**
+             * @type {float}
+             */
+            this._playRate = 1;
+
+            /**
+             * @private
+             */
+            this.onEnded = () => {
+                this.onEndedInternal();
+            };
+
+            /**
+             * @private
+             */
+            this.onTimeUpdate = (time) => {
+                if (time && !this._timeUpdated) {
+                    this._timeUpdated = true;
+                }
+
+                this._currentTime = time;
+                this.events.trigger(this, 'timeupdate');
+            };
+
+            /**
+             * @private
+             */
+            this.onNavigatedToOsd = () => {
+                const dlg = this._videoDialog;
+                if (dlg) {
+                    dlg.style.zIndex = 'unset';
+                }
+            };
+
+            /**
+             * @private
+             */
+            this.onPlaying = () => {
+                if (!this._started) {
+                    this._started = true;
+
+                    this.loading.hide();
+
+                    const volume = this.getSavedVolume() * 100;
+                    if (volume != this._volume) {
+                        this.setVolume(volume, false);
+                    }
+
+                    this.setPlaybackRate(1);
+                    this.setMute(false);
+
+                    if (this._currentPlayOptions.fullscreen) {
+                        this.appRouter.showVideoOsd().then(this.onNavigatedToOsd);
+                    } else {
+                        this.appRouter.setTransparency('backdrop');
+                        this._videoDialog.dlg.style.zIndex = 'unset';
+                    }
+
+                    // Need to override default style.
+                    this._videoDialog.style.setProperty('background', 'transparent', 'important');
+                }
+
+                if (this._paused) {
+                    this._paused = false;
+                    this.events.trigger(this, 'unpause');
+                }
+
+                this.events.trigger(this, 'playing');
+            };
+
+            /**
+             * @private
+             */
+            this.onPause = () => {
+                this._paused = true;
+                // For Syncplay ready notification
+                this.events.trigger(this, 'pause');
+            };
+
+            this.onWaiting = () => {
+                this.events.trigger(this, 'waiting');
+            };
+
+            /**
+             * @private
+             * @param e {Event} The event received from the `<video>` element
+             */
+            this.onError = (error) => {
+                console.error(`media element error: ${error}`);
+
+                this.events.trigger(this, 'error', [
+                    {
+                        type: 'mediadecodeerror'
+                    }
+                ]);
+            };
+
+            this.onDuration = (duration) => {
+                this._duration = duration;
+            };
+
+        }
+
+        currentSrc() {
+            return this._currentSrc;
+        }
+
+        async play(options) {
+            this._started = false;
+            this._timeUpdated = false;
+            this._currentTime = null;
+
+            this.resetSubtitleOffset();
+            this.loading.show();
+            window.api.power.setScreensaverEnabled(false);
+            const elem = await this.createMediaElement(options);
+            return await this.setCurrentSrc(elem, options);
+        }
+
+        getSavedVolume() {
+            return this.appSettings.get('volume') || 1;
+        }
+
+        /**
+         * @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;
+        }
+
+        tryGetFramerate(options) {
+            if (options.mediaSource && options.mediaSource.MediaStreams) {
+                for (let stream of options.mediaSource.MediaStreams) {
+                    if (stream.Type == "Video") {
+                        return stream.RealFrameRate || stream.AverageFrameRate || null;
+                    }
+                }
+            }
+        }
+
+        /**
+         * @private
+         */
+        setCurrentSrc(elem, options) {
+            return new Promise((resolve) => {
+                const val = options.url;
+                this._currentSrc = val;
+                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 streamdata = {type: 'video', headers: {'User-Agent': 'JellyfinMediaPlayer'}, media: {}};
+                const fps = this.tryGetFramerate(options);
+                if (fps) {
+                    streamdata.frameRate = fps;
+                }
+
+                const player = window.api.player;
+                player.load(val,
+                    { startMilliseconds: ms, autoplay: true },
+                    streamdata,
+                    (this._audioTrackIndexToSetOnPlaying != null)
+                     ? '#' + this._audioTrackIndexToSetOnPlaying : '#1',
+                    this.getSubtitleParam(),
+                    resolve);
+            });
+        }
+
+        setSubtitleStreamIndex(index) {
+            this._subtitleTrackIndexToSetOnPlaying = index;
+            window.api.player.setSubtitleStream(this.getSubtitleParam());
+        }
+
+        resetSubtitleOffset() {
+            this._currentTrackOffset = 0;
+            this._showTrackOffset = false;
+            window.api.player.setSubtitleDelay(0);
+        }
+
+        enableShowingSubtitleOffset() {
+            this._showTrackOffset = true;
+        }
+
+        disableShowingSubtitleOffset() {
+            this._showTrackOffset = false;
+        }
+
+        isShowingSubtitleOffsetEnabled() {
+            return this._showTrackOffset;
+        }
+
+        setSubtitleOffset(offset) {
+            const offsetValue = parseFloat(offset);
+            this._currentTrackOffset = offsetValue;
+            window.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);
+            });
+        }
+
+        setAudioStreamIndex(index) {
+            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;
+            }
+
+            window.api.player.setAudioStream(index != -1 ? '#' + index : '');
+        }
+
+        onEndedInternal() {
+            const stopInfo = {
+                src: this._currentSrc
+            };
+
+            this.events.trigger(this, 'stopped', [stopInfo]);
+
+            this._currentTime = null;
+            this._currentSrc = null;
+            this._currentPlayOptions = null;
+        }
+
+        stop(destroyPlayer) {
+            window.api.player.stop();
+            window.api.power.setScreensaverEnabled(true);
+
+            this.onEndedInternal();
+
+            if (destroyPlayer) {
+                this.destroy();
+            }
+            return Promise.resolve();
+        }
+
+        destroy() {
+            window.api.player.stop();
+            window.api.power.setScreensaverEnabled(true);
+
+            this.appRouter.setTransparency('none');
+            document.body.classList.remove('hide-scroll');
+
+            const player = window.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);
+            }
+
+            // Only supporting QtWebEngine here
+            if (document.webkitIsFullScreen && document.webkitExitFullscreen) {
+                document.webkitExitFullscreen();
+            }
+        }
+
+        /**
+         * @private
+         */
+        createMediaElement(options) {
+            const dlg = document.querySelector('.videoPlayerContainer');
+
+            if (!dlg) {
+                this.loading.show();
+
+                const dlg = document.createElement('div');
+
+                dlg.classList.add('videoPlayerContainer');
+                dlg.style.position = 'fixed';
+                dlg.style.top = 0;
+                dlg.style.bottom = 0;
+                dlg.style.left = 0;
+                dlg.style.right = 0;
+                dlg.style.display = 'flex';
+                dlg.style.alignItems = 'center';
+
+                if (options.fullscreen) {
+                    dlg.style.zIndex = 1000;
+                }
+
+                const html = '';
+
+                dlg.innerHTML = html;
+
+                document.body.insertBefore(dlg, document.body.firstChild);
+                this._videoDialog = dlg;
+                const player = window.api.player;
+                player.playing.connect(this.onPlaying);
+                player.positionUpdate.connect(this.onTimeUpdate);
+                player.finished.connect(this.onEnded);
+                player.updateDuration.connect(this.onDuration);
+                player.error.connect(this.onError);
+                player.paused.connect(this.onPause);
+
+                if (options.fullscreen) {
+                    // At this point, we must hide the scrollbar placeholder, so it's not being displayed while the item is being loaded
+                    document.body.classList.add('hide-scroll');
+                }
+                return Promise.resolve();
+            } else {
+                // we need to hide scrollbar when starting playback from page with animated background
+                if (options.fullscreen) {
+                    document.body.classList.add('hide-scroll');
+                }
+
+                return Promise.resolve();
+            }
+        }
+
+    /**
+     * @private
+     */
+    canPlayMediaType(mediaType) {
+        return (mediaType || '').toLowerCase() === 'video';
+    }
+
+    /**
+     * @private
+     */
+    supportsPlayMethod() {
+        return true;
+    }
+
+    /**
+     * @private
+     */
+    getDeviceProfile(item, options) {
+        if (this.appHost.getDeviceProfile) {
+            return this.appHost.getDeviceProfile(item, options);
+        }
+
+        return Promise.resolve({});
+    }
+
+    /**
+     * @private
+     */
+    static getSupportedFeatures() {
+        return ['PlaybackRate'];
+    }
+
+    supports(feature) {
+        if (!this._supportedFeatures) {
+            this._supportedFeatures = mpvVideoPlayer.getSupportedFeatures();
+        }
+
+        return this._supportedFeatures.includes(feature);
+    }
+
+    // Save this for when playback stops, because querying the time at that point might return 0
+    currentTime(val) {
+        if (val != null) {
+            window.api.player.seekTo(val);
+            return;
+        }
+
+        return this._currentTime;
+    }
+
+    currentTimeAsync() {
+        return new Promise((resolve) => {
+            window.api.player.getPosition(resolve);
+        });
+    }
+
+    duration() {
+        if (this._duration) {
+            return this._duration;
+        }
+
+        return null;
+    }
+
+    canSetAudioStreamIndex() {
+        return true;
+    }
+
+    static onPictureInPictureError(err) {
+        console.error(`Picture in picture error: ${err}`);
+    }
+
+    setPictureInPictureEnabled() {}
+
+    isPictureInPictureEnabled() {
+        return false;
+    }
+
+    isAirPlayEnabled() {
+        return false;
+    }
+
+    setAirPlayEnabled() {}
+
+    setBrightness() {}
+
+    getBrightness() {
+        return 100;
+    }
+
+    seekable() {
+        return Boolean(this._duration);
+    }
+
+    pause() {
+        window.api.player.pause();
+    }
+
+    // This is a retry after error
+    resume() {
+        this._paused = false;
+        window.api.player.play();
+    }
+
+    unpause() {
+        window.api.player.play();
+    }
+
+    paused() {
+        return this._paused;
+    }
+
+    setPlaybackRate(value) {
+        this._playRate = value;
+        window.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
+        }];
+    }
+
+    saveVolume(value) {
+        if (value) {
+            this.appSettings.set('volume', value);
+        }
+    }
+
+    setVolume(val, save = true) {
+        this._volume = val;
+        if (save) {
+            this.saveVolume((val || 100) / 100);
+            this.events.trigger(this, 'volumechange');
+        }
+        window.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));
+    }
+
+    setMute(mute) {
+        this._muted = mute;
+        window.api.player.setMuted(mute);
+    }
+
+    isMuted() {
+        return this._muted;
+    }
+
+    setAspectRatio() {
+    }
+
+    getAspectRatio() {
+        return this._currentAspectRatio || 'auto';
+    }
+
+    getSupportedAspectRatios() {
+        return [{
+            name: this.globalize.translate('Auto'),
+            id: 'auto'
+        }];
+    }
+
+    togglePictureInPicture() {
+    }
+
+    toggleAirPlay() {
+    }
+
+    getBufferedRanges() {
+        return [];
+    }
+
+    getStats() {
+        const playOptions = this._currentPlayOptions || [];
+        const categories = [];
+
+        if (!this._currentPlayOptions) {
+            return Promise.resolve({
+                categories: categories
+            });
+        }
+
+        const mediaCategory = {
+            stats: [],
+            type: 'media'
+        };
+        categories.push(mediaCategory);
+
+        if (playOptions.url) {
+            //  create an anchor element (note: no need to append this element to the document)
+            let link = document.createElement('a');
+            //  set href to any path
+            link.setAttribute('href', playOptions.url);
+            const protocol = (link.protocol || '').replace(':', '');
+
+            if (protocol) {
+                mediaCategory.stats.push({
+                    label: this.globalize.translate('LabelProtocol'),
+                    value: protocol
+                });
+            }
+
+            link = null;
+        }
+
+        mediaCategory.stats.push({
+            label: this.globalize.translate('LabelStreamType'),
+            value: 'Video'
+        });
+
+        const videoCategory = {
+            stats: [],
+            type: 'video'
+        };
+        categories.push(videoCategory);
+
+        const audioCategory = {
+            stats: [],
+            type: 'audio'
+        };
+        categories.push(audioCategory);
+
+        return Promise.resolve({
+            categories: categories
+        });
+    }
+    }
+/* eslint-enable indent */
+
+window._mpvVideoPlayer = mpvVideoPlayer;

+ 125 - 0
native/nativeshell.js

@@ -0,0 +1,125 @@
+const viewdata = JSON.parse(window.atob("@@data@@"));
+console.log(viewdata);
+
+const features = [
+    "filedownload",
+    "displaylanguage",
+    "htmlaudioautoplay",
+    "htmlvideoautoplay",
+    "externallinks",
+    //"clientsettings",
+    "multiserver",
+    "remotecontrol",
+];
+
+const plugins = [
+    'mpvVideoPlayer',
+    'mpvAudioPlayer',
+];
+
+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);
+    });
+}
+
+// Add plugin loaders
+for (const plugin of plugins) {
+    window[plugin] = async () => {
+        await loadScript(`${viewdata.scriptPath}${plugin}.js`);
+        return window["_" + plugin];
+    };
+}
+
+window.NativeShell = {
+    openUrl(url, target) {
+        window.api.system.openExternalUrl(url);
+    },
+
+    downloadFile(downloadInfo) {
+        window.api.system.openExternalUrl(downloadInfo.url);
+    },
+
+    //openClientSettings() {
+    //    window.NativeInterface.openClientSettings();
+    //},
+
+    getPlugins() {
+        return plugins;
+    }
+};
+
+function getDeviceProfile() {
+    return {
+        'Name': 'Jellyfin Media Player',
+        'MusicStreamingTranscodingBitrate': 1280000,
+        'TimelineOffsetSeconds': 5,
+        'TranscodingProfiles': [
+            {'Type': 'Audio'},
+            {
+                'Container': 'ts',
+                'Type': 'Video',
+                'Protocol': 'hls',
+                'AudioCodec': 'aac,mp3,ac3,opus,flac,vorbis',
+                'VideoCodec': 'h264,h265,hevc,mpeg4,mpeg2video',
+                'MaxAudioChannels': '6'
+            },
+            {'Container': 'jpeg', 'Type': 'Photo'}
+        ],
+        'DirectPlayProfiles': [{'Type': 'Video'}, {'Type': 'Audio'}, {'Type': 'Photo'}],
+        'ResponseProfiles': [],
+        'ContainerProfiles': [],
+        'CodecProfiles': [],
+        'SubtitleProfiles': [
+            {'Format': 'srt', 'Method': 'External'},
+            {'Format': 'srt', 'Method': 'Embed'},
+            {'Format': 'ass', 'Method': 'External'},
+            {'Format': 'ass', 'Method': 'Embed'},
+            {'Format': 'sub', 'Method': 'Embed'},
+            {'Format': 'sub', 'Method': 'External'},
+            {'Format': 'ssa', 'Method': 'Embed'},
+            {'Format': 'ssa', 'Method': 'External'},
+            {'Format': 'smi', 'Method': 'Embed'},
+            {'Format': 'smi', 'Method': 'External'},
+            {'Format': 'pgssub', 'Method': 'Embed'},
+            {'Format': 'dvdsub', 'Method': 'Embed'},
+            {'Format': 'pgs', 'Method': 'Embed'}
+        ]
+    };
+}
+
+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;
+}
+
+window.NativeShell.AppHost = {
+    async init() {
+        window.api = await createApi();
+    },
+    getDefaultLayout() {
+        return "desktop";
+    },
+    supports(command) {
+        return features.includes(command.toLowerCase());
+    },
+    getDeviceProfile,
+    getSyncProfile: getDeviceProfile,
+    appName() {
+        return "Jellyfin Media Player";
+    },
+    appVersion() {
+        return navigator.userAgent.split(" ")[1];
+    },
+    deviceName() {
+        return viewdata.deviceName;
+    }
+};

+ 2 - 0
src/CMakeLists.txt

@@ -109,6 +109,7 @@ set(RESOURCE_ROOT .)
 if(APPLE)
   set(RESOURCE_ROOT Resources)
   add_resources(TARGET ${MAIN_TARGET} SOURCES ${CMAKE_CURRENT_BINARY_DIR}/../dist/ DEST ${RESOURCE_ROOT}/web-client/desktop)
+  add_resources(TARGET ${MAIN_TARGET} SOURCES ${CMAKE_SOURCE_DIR}/native/ DEST ${RESOURCE_ROOT}/web-client/extension)
 endif()
 
 if(NOT APPLE)
@@ -122,6 +123,7 @@ if(NOT APPLE)
     endif()
   endforeach()
   install(DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/../dist/ DESTINATION ${INSTALL_RESOURCE_DIR}/web-client/desktop)
+  install(DIRECTORY ${CMAKE_SOURCE_DIR}/native/ DESTINATION ${INSTALL_RESOURCE_DIR}/web-client/extension)
 endif()
 
 if(XCODE)

+ 0 - 12
src/player/PlayerComponent.cpp

@@ -290,18 +290,6 @@ bool PlayerComponent::load(const QString& url, const QVariantMap& options, const
   return true;
 }
 
-///////////////////////////////////////////////////////////////////////////////////////////////////
-static bool IsPlexDirectURL(const QString& host)
-{
-  return false;
-}
-
-///////////////////////////////////////////////////////////////////////////////////////////////////
-static QString ConvertPlexDirectURL(const QString& host)
-{
-  return host;
-}
-
 ///////////////////////////////////////////////////////////////////////////////////////////////////
 void PlayerComponent::queueMedia(const QString& url, const QVariantMap& options, const QVariantMap &metadata, const QString& audioStream, const QString& subtitleStream)
 {

+ 29 - 6
src/settings/SettingsComponent.cpp

@@ -727,10 +727,6 @@ QString SettingsComponent::getWebClientUrl(bool desktop)
 {
   QString url;
 
-#ifdef KONVERGO_OPENELEC
-  desktop = false;
-#endif
-
   if (desktop)
     url = SettingsComponent::Get().value(SETTINGS_SECTION_PATH, "startupurl_desktop").toString();
   else
@@ -746,8 +742,6 @@ QString SettingsComponent::getWebClientUrl(bool desktop)
   if (url == "bundled")
   {
     auto path = Paths::webClientPath(desktop ? "desktop" : "tv");
-    if (path.startsWith("/"))
-      url = "file://" + path;
     url = "file:///" + path;
   }
 
@@ -756,6 +750,35 @@ QString SettingsComponent::getWebClientUrl(bool desktop)
   return url;
 }
 
+/////////////////////////////////////////////////////////////////////////////////////////
+QString SettingsComponent::getExtensionPath()
+{
+  QString url;
+
+  url = SettingsComponent::Get().value(SETTINGS_SECTION_PATH, "startupurl_extension").toString();
+
+  if (url == "bundled")
+  {
+    auto path = Paths::webExtensionPath("extension");
+    url = path;
+  }
+
+  return url;
+}
+
+/////////////////////////////////////////////////////////////////////////////////////////
+QString SettingsComponent::getClientName()
+{
+  QString name;
+  name = SettingsComponent::Get().value(SETTINGS_SECTION_SYSTEM, "systemname").toString();
+
+  if (name.compare("JellyfinMediaPlayer") == 0) {
+    name = Utils::ComputerName();
+  }
+
+  return name;
+}
+
 /////////////////////////////////////////////////////////////////////////////////////////
 void SettingsComponent::setCommandLineValues(const QStringList& values)
 {

+ 2 - 0
src/settings/SettingsComponent.h

@@ -58,6 +58,8 @@ public:
   Q_INVOKABLE void resetToDefault(const QString& sectionID);
   Q_INVOKABLE QVariantList settingDescriptions();
   Q_INVOKABLE QString getWebClientUrl(bool desktop);
+  Q_INVOKABLE QString getExtensionPath();
+  Q_INVOKABLE QString getClientName();
 
   // host commands
   Q_SLOT Q_INVOKABLE void cycleSettingCommand(const QString& args);

+ 7 - 0
src/shared/Paths.cpp

@@ -133,3 +133,10 @@ QString Paths::webClientPath(const QString& mode)
   QString webName = QString("web-client/%1").arg(mode);
   return resourceDir(webName + "/index.html");
 }
+
+/////////////////////////////////////////////////////////////////////////////////////////
+QString Paths::webExtensionPath(const QString& mode)
+{
+  QString webName = QString("web-client/%1").arg(mode);
+  return resourceDir(webName + "/");
+}

+ 1 - 0
src/shared/Paths.h

@@ -18,6 +18,7 @@ namespace Paths
   QString socketName(const QString& serverName);
   QString soundsPath(const QString& sound);
   QString webClientPath(const QString& mode = "tv");
+  QString webExtensionPath(const QString& mode = "extension");
 };
 
 #endif //KONVERGO_PATHS_H

+ 18 - 0
src/system/SystemComponent.cpp

@@ -5,6 +5,7 @@
 #include <QGuiApplication>
 #include <QDesktopServices>
 #include <QDir>
+#include <QJsonObject>
 
 #include "input/InputComponent.h"
 #include "SystemComponent.h"
@@ -334,6 +335,23 @@ void SystemComponent::hello(const QString& version)
   m_webClientVersion = version;
 }
 
+/////////////////////////////////////////////////////////////////////////////////////////
+QString SystemComponent::getNativeShellScript()
+{
+  auto path = SettingsComponent::Get().getExtensionPath();
+  QLOG_DEBUG() << QString("Using path for extension: %1").arg(path);
+
+  QFile file {path + "nativeshell.js"};
+  file.open(QIODevice::ReadOnly);
+  auto nativeshellString = QTextStream(&file).readAll();
+  QJsonObject clientData;
+  clientData.insert("deviceName", QJsonValue::fromVariant(SettingsComponent::Get().getClientName()));
+  clientData.insert("scriptPath", QJsonValue::fromVariant("file:///" + path));
+  nativeshellString.replace("@@data@@", QJsonDocument(clientData).toJson(QJsonDocument::Compact).toBase64());
+  QLOG_DEBUG() << nativeshellString;
+  return nativeshellString;
+}
+
 /////////////////////////////////////////////////////////////////////////////////////////
 #define BASESTR "protocols=shoutcast,http-video;videoDecoders=h264{profile:high&resolution:2160&level:52};audioDecoders=mp3,aac,dts{bitrate:800000&channels:%1},ac3{bitrate:800000&channels:%2}"
 

+ 2 - 0
src/system/SystemComponent.h

@@ -47,6 +47,8 @@ public:
 
   Q_INVOKABLE void runUserScript(QString script);
 
+  Q_INVOKABLE QString getNativeShellScript();
+
   // called by the web-client when everything is properly inited
   Q_INVOKABLE void hello(const QString& version);
 

+ 22 - 0
src/ui/webview.qml

@@ -115,6 +115,20 @@ KonvergoWindow
     id: action_redo
   }
 
+  Action
+  {
+    shortcut: StandardKey.Back
+    onTriggered: runWebAction(WebEngineView.Back)
+    id: action_back
+  }
+
+  Action
+  {
+    shortcut: StandardKey.Forward
+    onTriggered: runWebAction(WebEngineView.Forward)
+    id: action_forward
+  }
+
   MpvVideo
   {
     id: video
@@ -138,6 +152,14 @@ KonvergoWindow
     onLinkHovered: web.currentHoveredUrl = hoveredUrl
     width: mainWindow.width
     height: mainWindow.height
+    userScripts: [
+      WebEngineScript
+      {
+        sourceCode: components.system.getNativeShellScript()
+        injectionPoint: WebEngineScript.DocumentCreation
+        worldId: WebEngineScript.MainWorld
+      }
+    ]
 
     Component.onCompleted:
     {