From cd99f4e59372dea9fb1aa6122dc336123f01d0ff Mon Sep 17 00:00:00 2001 From: Aleksi Lassila Date: Wed, 16 Aug 2023 13:23:19 +0300 Subject: [PATCH] feat: Video player UI improvements, double click to fullscreen and click to pause --- README.md | 9 + docker-compose.prod.yml | 2 +- src/lib/apis/jellyfin/jellyfinApi.ts | 6 +- src/lib/apis/jellyfin/qualities.ts | 114 ++++---- .../components/ContextMenu/ContextMenu.svelte | 5 +- .../SelectableContextMenuItem.svelte | 22 ++ .../ContextMenu/SelectableMenuItem.svelte | 19 -- src/lib/components/VideoPlayer/Slider.svelte | 72 ++--- .../components/VideoPlayer/VideoPlayer.svelte | 254 +++++++++++------- 9 files changed, 292 insertions(+), 211 deletions(-) create mode 100644 src/lib/components/ContextMenu/SelectableContextMenuItem.svelte delete mode 100644 src/lib/components/ContextMenu/SelectableMenuItem.svelte diff --git a/README.md b/README.md index 16d7680..1eae998 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,15 @@ PUBLIC_JELLYFIN_BASE_URL=http://127.0.0.1:8096 For Webstorm users: I'd recommend using VS Code as it has way better Svelte Typescript support. +Useful resources: + +- https://developer.themoviedb.org/reference +- https://api.jellyfin.org/ +- https://sonarr.tv/docs/api/ +- https://radarr.video/docs/api/ +- https://github.com/jellyfin/jellyfin-web +- Network tab in the browser in Jellyfin, Radarr & Sonarr web UIs + # Additional Screenshots ![Landing Page](images/screenshot-1.png) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a7134ef..474610c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,4 +9,4 @@ services: context: . target: production ports: - - 9494:3000 + - 9494:9494 diff --git a/src/lib/apis/jellyfin/jellyfinApi.ts b/src/lib/apis/jellyfin/jellyfinApi.ts index 10dc7bb..9c1cb02 100644 --- a/src/lib/apis/jellyfin/jellyfinApi.ts +++ b/src/lib/apis/jellyfin/jellyfinApi.ts @@ -158,7 +158,7 @@ export const getJellyfinPlaybackInfo = async ( userId, startTimeTicks, autoOpenLiveStream: true, - maxStreamingBitrate: maxStreamingBitrate + maxStreamingBitrate } }, body: { @@ -230,9 +230,7 @@ export const reportJellyfinPlaybackStopped = ( } }); -export const delteActiveEncoding = ( - playSessionId: string -) => +export const delteActiveEncoding = (playSessionId: string) => JellyfinApi?.del('/Videos/ActiveEncodings', { params: { query: { diff --git a/src/lib/apis/jellyfin/qualities.ts b/src/lib/apis/jellyfin/qualities.ts index 1f46706..2384426 100644 --- a/src/lib/apis/jellyfin/qualities.ts +++ b/src/lib/apis/jellyfin/qualities.ts @@ -1,64 +1,64 @@ /** * Returns an array containing all the available * qualities the user can select when playing a video - * + * * @param resolution The resolution of the video * @returns An array containing all the available qualities */ -export function getQualities(resolution : number) { - // We add one to the minimum resolution since some movies - // have a resolution of 1080p, but the format isn't 16:9, - // so the height is less than 1080, so we detect as 1080p - // anything higher than 720p, and so on for the other. - let data = [ - { - name: "4K - 120 Mbps", - maxBitrate: 120000000, - minResolution: 1080 + 1 - }, - { - name: "4K - 80 Mbps", - maxBitrate: 80000000, - minResolution: 1080 + 1 - }, - { - name: "1080p - 40 Mbps", - maxBitrate: 40000000, - minResolution: 720 + 1 - }, - { - name: "1080p - 10 Mbps", - maxBitrate: 10000000, - minResolution: 720 + 1 - }, - { - name: "720p - 8 Mbps", - maxBitrate: 8000000, - minResolution: 480 + 1 - }, - { - name: "720p - 4 Mbps", - maxBitrate: 4000000, - minResolution: 480 + 1 - }, - { - name: "480p - 3 Mbps", - maxBitrate: 3000000, - minResolution: 360 + 1 - }, - { - name: "480p - 720 Kbps", - maxBitrate: 720000, - minResolution: 360 + 1 - }, - { - name: "360p - 420 Kbps", - maxBitrate: 420000, - minResolution: 0 - } - ] +export function getQualities(resolution: number) { + // We add one to the minimum resolution since some movies + // have a resolution of 1080p, but the format isn't 16:9, + // so the height is less than 1080, so we detect as 1080p + // anything higher than 720p, and so on for the other. + const data = [ + { + name: '4K - 120 Mbps', + maxBitrate: 120000000, + minResolution: 1080 + 1 + }, + { + name: '4K - 80 Mbps', + maxBitrate: 80000000, + minResolution: 1080 + 1 + }, + { + name: '1080p - 40 Mbps', + maxBitrate: 40000000, + minResolution: 720 + 1 + }, + { + name: '1080p - 10 Mbps', + maxBitrate: 10000000, + minResolution: 720 + 1 + }, + { + name: '720p - 8 Mbps', + maxBitrate: 8000000, + minResolution: 480 + 1 + }, + { + name: '720p - 4 Mbps', + maxBitrate: 4000000, + minResolution: 480 + 1 + }, + { + name: '480p - 3 Mbps', + maxBitrate: 3000000, + minResolution: 360 + 1 + }, + { + name: '480p - 720 Kbps', + maxBitrate: 720000, + minResolution: 360 + 1 + }, + { + name: '360p - 420 Kbps', + maxBitrate: 420000, + minResolution: 0 + } + ]; - return data.filter((quality) => { - return quality.minResolution <= resolution - }); -} \ No newline at end of file + return data.filter((quality) => { + return quality.minResolution <= resolution; + }); +} diff --git a/src/lib/components/ContextMenu/ContextMenu.svelte b/src/lib/components/ContextMenu/ContextMenu.svelte index bcd56a3..2227304 100644 --- a/src/lib/components/ContextMenu/ContextMenu.svelte +++ b/src/lib/components/ContextMenu/ContextMenu.svelte @@ -74,7 +74,10 @@ style={position === 'fixed' ? `left: ${ fixedPosition.x - (fixedPosition.x > windowWidth / 2 ? menu?.clientWidth : 0) - }px; top: ${fixedPosition.y - (bottom ? (fixedPosition.y > windowHeight / 2 ? menu?.clientHeight : 0) : 0)}px;` + }px; top: ${ + fixedPosition.y - + (bottom ? (fixedPosition.y > windowHeight / 2 ? menu?.clientHeight : 0) : 0) + }px;` : menu?.getBoundingClientRect()?.left > windowWidth / 2 ? `right: 0;${bottom ? 'bottom: 40px;' : ''}` : `left: 0;${bottom ? 'bottom: 40px;' : ''}`} diff --git a/src/lib/components/ContextMenu/SelectableContextMenuItem.svelte b/src/lib/components/ContextMenu/SelectableContextMenuItem.svelte new file mode 100644 index 0000000..ccbba36 --- /dev/null +++ b/src/lib/components/ContextMenu/SelectableContextMenuItem.svelte @@ -0,0 +1,22 @@ + + + +
+ +
+ +
+
+
diff --git a/src/lib/components/ContextMenu/SelectableMenuItem.svelte b/src/lib/components/ContextMenu/SelectableMenuItem.svelte deleted file mode 100644 index eae5293..0000000 --- a/src/lib/components/ContextMenu/SelectableMenuItem.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - -
- -
- -
-
-
\ No newline at end of file diff --git a/src/lib/components/VideoPlayer/Slider.svelte b/src/lib/components/VideoPlayer/Slider.svelte index 28c61e9..9191763 100644 --- a/src/lib/components/VideoPlayer/Slider.svelte +++ b/src/lib/components/VideoPlayer/Slider.svelte @@ -1,39 +1,47 @@ -
- -
-
- -
-
+
+
+
+ +
- -
-
-
+ +
+
-
-
+
+
- -
\ No newline at end of file + +
diff --git a/src/lib/components/VideoPlayer/VideoPlayer.svelte b/src/lib/components/VideoPlayer/VideoPlayer.svelte index 1684b86..55e03a3 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -6,26 +6,36 @@ reportJellyfinPlaybackProgress, reportJellyfinPlaybackStarted, reportJellyfinPlaybackStopped, - delteActiveEncoding + delteActiveEncoding as deleteActiveEncoding } from '$lib/apis/jellyfin/jellyfinApi'; import { getQualities } from '$lib/apis/jellyfin/qualities'; import getDeviceProfile from '$lib/apis/jellyfin/playback-profiles'; import classNames from 'classnames'; import Hls from 'hls.js'; - import { Cross2, Play, Pause, EnterFullScreen, ExitFullScreen, - SpeakerLoud, SpeakerModerate, SpeakerQuiet, SpeakerOff, Gear } from 'radix-icons-svelte'; + import { + Cross2, + Play, + Pause, + EnterFullScreen, + ExitFullScreen, + SpeakerLoud, + SpeakerModerate, + SpeakerQuiet, + SpeakerOff, + Gear + } from 'radix-icons-svelte'; import { onDestroy, onMount } from 'svelte'; import IconButton from '../IconButton.svelte'; import { playerState } from './VideoPlayer'; import { modalStack } from '../Modal/Modal'; import { JELLYFIN_BASE_URL } from '$lib/constants'; import { contextMenu } from '../ContextMenu/ContextMenu'; - import Slider from './Slider.svelte' + import Slider from './Slider.svelte'; import ContextMenu from '../ContextMenu/ContextMenu.svelte'; - import SelectableMenuItem from '../ContextMenu/SelectableMenuItem.svelte'; + import SelectableContextMenuItem from '../ContextMenu/SelectableContextMenuItem.svelte'; export let modalId: symbol; - + let qualityContextMenuId = Symbol(); let video: HTMLVideoElement; @@ -37,7 +47,7 @@ let progressInterval: NodeJS.Timeout; // These functions are different in every browser - let reqFullscreenFunc: ((elem : HTMLElement) => void) | undefined = undefined; + let reqFullscreenFunc: ((elem: HTMLElement) => void) | undefined = undefined; let exitFullscreen: (() => void) | undefined = undefined; let fullscreenChangeEvent: string | undefined = undefined; let getFullscreenElement: (() => HTMLElement) | undefined = undefined; @@ -46,60 +56,72 @@ let elem = document.createElement('div'); // @ts-ignore if (elem.requestFullscreen) { - reqFullscreenFunc = (elem) => { elem.requestFullscreen(); }; + reqFullscreenFunc = (elem) => { + elem.requestFullscreen(); + }; fullscreenChangeEvent = 'fullscreenchange'; - getFullscreenElement = () => document.fullscreenElement; + getFullscreenElement = () => document.fullscreenElement; if (document.exitFullscreen) exitFullscreen = () => document.exitFullscreen(); - // @ts-ignore - } else if (elem.webkitRequestFullscreen) { // @ts-ignore - reqFullscreenFunc = (elem) => { elem.webkitRequestFullscreen(); }; + } else if (elem.webkitRequestFullscreen) { + reqFullscreenFunc = (elem) => { + // @ts-ignore + elem.webkitRequestFullscreen(); + }; fullscreenChangeEvent = 'webkitfullscreenchange'; // @ts-ignore - getFullscreenElement = () => document.webkitFullscreenElement; - // @ts-ignore - if (document.webkitExitFullscreen) exitFullscreen = () => document.webkitExitFullscreen(); - // @ts-ignore - } else if (elem.msRequestFullscreen) { + getFullscreenElement = () => document.webkitFullscreenElement; // @ts-ignore - reqFullscreenFunc = (elem) => { elem.msRequestFullscreen(); } + if (document.webkitExitFullscreen) exitFullscreen = () => document.webkitExitFullscreen(); + // @ts-ignore + } else if (elem.msRequestFullscreen) { + reqFullscreenFunc = (elem) => { + // @ts-ignore + elem.msRequestFullscreen(); + }; fullscreenChangeEvent = 'MSFullscreenChange'; // @ts-ignore - getFullscreenElement = () => document.msFullscreenElement; + getFullscreenElement = () => document.msFullscreenElement; // @ts-ignore if (document.msExitFullscreen) exitFullscreen = () => document.msExitFullscreen(); - // @ts-ignore - } else if (elem.mozRequestFullScreen) { // @ts-ignore - reqFullscreenFunc = (elem) => { elem.mozRequestFullScreen(); }; + } else if (elem.mozRequestFullScreen) { + reqFullscreenFunc = (elem) => { + // @ts-ignore + elem.mozRequestFullScreen(); + }; fullscreenChangeEvent = 'mozfullscreenchange'; // @ts-ignore - getFullscreenElement = () => document.mozFullScreenElement; + getFullscreenElement = () => document.mozFullScreenElement; // @ts-ignore if (document.mozCancelFullScreen) exitFullscreen = () => document.mozCancelFullScreen(); } - let paused : boolean; - let duration : number = 0; - let displayedTime : number = 0; - let bufferedTime : number = 0; + let paused: boolean; + let duration: number = 0; + let displayedTime: number = 0; + let bufferedTime: number = 0; - let videoLoaded : boolean = false; - let seeking : boolean = false; - let playerStateBeforeSeek : boolean; + let videoLoaded: boolean = false; + let seeking: boolean = false; + let playerStateBeforeSeek: boolean; - let fullscreen : boolean = false; - let volume : number = 1; - let mute : boolean = false; + let fullscreen: boolean = false; + let volume: number = 1; + let mute: boolean = false; - let resolution : number = 1080; - let currentBitrate : number = 0; + let resolution: number = 1080; + let currentBitrate: number = 0; let shouldCloseUi = false; let uiVisible = true; - $: uiVisible = !shouldCloseUi || seeking || paused || $contextMenu === qualityContextMenuId + $: uiVisible = !shouldCloseUi || seeking || paused || $contextMenu === qualityContextMenuId; - const fetchPlaybackInfo = (itemId: string, maxBitrate: number | undefined = undefined, starting : boolean = true) => + const fetchPlaybackInfo = ( + itemId: string, + maxBitrate: number | undefined = undefined, + starting: boolean = true + ) => getJellyfinItem(itemId).then((item) => getJellyfinPlaybackInfo( itemId, @@ -125,13 +147,13 @@ const hls = new Hls(); hls.loadSource(JELLYFIN_BASE_URL + playbackUri); - hls.attachMedia(video); + hls.attachMedia(video); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { /* - * HLS.js does NOT work on iOS on iPhone because Safari on iPhone does not support MSE. - * This is not a problem, since HLS is natively supported on iOS. But any other browser - * that does not support MSE will not be able to play the video. - */ + * HLS.js does NOT work on iOS on iPhone because Safari on iPhone does not support MSE. + * This is not a problem, since HLS is natively supported on iOS. But any other browser + * that does not support MSE will not be able to play the video. + */ video.src = JELLYFIN_BASE_URL + playbackUri; } else { throw new Error('HLS is not supported'); @@ -154,7 +176,8 @@ // A start report should only be sent when the video starts playing, // not every time a playback info request is made - if (mediaSourceId && starting) await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId); + if (mediaSourceId && starting) + await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId); reportProgress = async () => { await reportJellyfinPlaybackProgress( @@ -163,7 +186,7 @@ video?.paused == true, video?.currentTime * 10_000_000 ); - } + }; if (progressInterval) clearInterval(progressInterval); progressInterval = setInterval(() => { @@ -172,8 +195,8 @@ }, 5000); deleteEncoding = () => { - delteActiveEncoding(sessionId); - } + deleteActiveEncoding(sessionId); + }; stopCallback = () => { reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000); @@ -184,7 +207,7 @@ function onSeekStart() { if (seeking) return; - + playerStateBeforeSeek = paused; seeking = true; paused = true; @@ -201,7 +224,7 @@ function handleBuffer() { let timeRanges = video.buffered; - // Find the first one whose end time is after the current time + // Find the first one whose end time is after the current time // (the time ranges given by the browser are normalized, which means // that they are sorted and non-overlapping) for (let i = 0; i < timeRanges.length; i++) { @@ -220,7 +243,7 @@ modalStack.close(modalId); } - function handleUserInteraction(touch : boolean = false) { + function handleUserInteraction(touch: boolean = false) { if (touch) shouldCloseUi = !shouldCloseUi; else shouldCloseUi = false; @@ -239,7 +262,7 @@ else contextMenu.show(qualityContextMenuId); } - async function handleSelectQuality(bitrate : number) { + async function handleSelectQuality(bitrate: number) { if (!$playerState.jellyfinId || !video || seeking) return; if (bitrate === currentBitrate) return; @@ -254,13 +277,13 @@ paused = stateBeforeLoad; } - function secondsToTime(seconds : number, forceHours = false) { + function secondsToTime(seconds: number, forceHours = false) { if (isNaN(seconds)) return '00:00'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds - hours * 3600) / 60); const secondsLeft = Math.floor(seconds - hours * 3600 - minutes * 60); - + let str = ''; if (hours > 0 || forceHours) str += `${hours}:`; @@ -307,78 +330,115 @@
-
handleUserInteraction(false)} on:touchend|preventDefault={() => handleUserInteraction(true)}> + +
handleUserInteraction(false)} + on:touchend|preventDefault={() => handleUserInteraction(true)} + on:dblclick|preventDefault={() => (fullscreen = !fullscreen)} + on:click={() => (paused = !paused)} + > -