feat: Video player UI improvements, double click to fullscreen and click to pause
This commit is contained in:
@@ -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
|
||||
|
||||

|
||||
|
||||
@@ -9,4 +9,4 @@ services:
|
||||
context: .
|
||||
target: production
|
||||
ports:
|
||||
- 9494:3000
|
||||
- 9494:9494
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -10,55 +10,55 @@ export function getQualities(resolution : number) {
|
||||
// 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 = [
|
||||
const data = [
|
||||
{
|
||||
name: "4K - 120 Mbps",
|
||||
name: '4K - 120 Mbps',
|
||||
maxBitrate: 120000000,
|
||||
minResolution: 1080 + 1
|
||||
},
|
||||
{
|
||||
name: "4K - 80 Mbps",
|
||||
name: '4K - 80 Mbps',
|
||||
maxBitrate: 80000000,
|
||||
minResolution: 1080 + 1
|
||||
},
|
||||
{
|
||||
name: "1080p - 40 Mbps",
|
||||
name: '1080p - 40 Mbps',
|
||||
maxBitrate: 40000000,
|
||||
minResolution: 720 + 1
|
||||
},
|
||||
{
|
||||
name: "1080p - 10 Mbps",
|
||||
name: '1080p - 10 Mbps',
|
||||
maxBitrate: 10000000,
|
||||
minResolution: 720 + 1
|
||||
},
|
||||
{
|
||||
name: "720p - 8 Mbps",
|
||||
name: '720p - 8 Mbps',
|
||||
maxBitrate: 8000000,
|
||||
minResolution: 480 + 1
|
||||
},
|
||||
{
|
||||
name: "720p - 4 Mbps",
|
||||
name: '720p - 4 Mbps',
|
||||
maxBitrate: 4000000,
|
||||
minResolution: 480 + 1
|
||||
},
|
||||
{
|
||||
name: "480p - 3 Mbps",
|
||||
name: '480p - 3 Mbps',
|
||||
maxBitrate: 3000000,
|
||||
minResolution: 360 + 1
|
||||
},
|
||||
{
|
||||
name: "480p - 720 Kbps",
|
||||
name: '480p - 720 Kbps',
|
||||
maxBitrate: 720000,
|
||||
minResolution: 360 + 1
|
||||
},
|
||||
{
|
||||
name: "360p - 420 Kbps",
|
||||
name: '360p - 420 Kbps',
|
||||
maxBitrate: 420000,
|
||||
minResolution: 0
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
return data.filter((quality) => {
|
||||
return quality.minResolution <= resolution
|
||||
return quality.minResolution <= resolution;
|
||||
});
|
||||
}
|
||||
@@ -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;' : ''}`}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'radix-icons-svelte';
|
||||
import ContextMenuItem from './ContextMenuItem.svelte';
|
||||
|
||||
export let selected = false;
|
||||
</script>
|
||||
|
||||
<ContextMenuItem on:click>
|
||||
<div class="flex items-center gap-2 justify-between cursor-pointer">
|
||||
<Check
|
||||
size={20}
|
||||
class={classNames({
|
||||
'opacity-0': !selected,
|
||||
'opacity-100': selected
|
||||
})}
|
||||
/>
|
||||
<div class="flex items-center text-left w-32">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { Check } from "radix-icons-svelte";
|
||||
import ContextMenuItem from "./ContextMenuItem.svelte";
|
||||
|
||||
export let selected = false;
|
||||
</script>
|
||||
|
||||
<ContextMenuItem on:click>
|
||||
<div class="flex items-center justify-between cursor-pointer">
|
||||
<Check size={20} class={classNames('mr-4', {
|
||||
'opacity-0': !selected,
|
||||
'opacity-100': selected
|
||||
})} />
|
||||
<div class="flex items-center text-left w-32">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
@@ -8,32 +8,40 @@
|
||||
let progressBarOffset = 0;
|
||||
</script>
|
||||
|
||||
<div class="h-2 relative group">
|
||||
<!-- 0.54 em is half the width of the input thumb size -->
|
||||
<div class="h-full relative px-[0.54em]">
|
||||
<div class="h-full bg-gray-300 rounded-full overflow-hidden relative">
|
||||
<div class="h-1 relative group">
|
||||
<div class="h-full relative px-[0.5rem]">
|
||||
<div class="h-full bg-zinc-200 bg-opacity-20 rounded-full overflow-hidden relative">
|
||||
<!-- Secondary progress -->
|
||||
<div class="h-full bg-gray-400 absolute top-0"
|
||||
style="width: {secondaryValue / max * 100}%;">
|
||||
</div>
|
||||
<div
|
||||
class="h-full bg-zinc-200 bg-opacity-20 absolute top-0"
|
||||
style="width: {(secondaryValue / max) * 100}%;"
|
||||
/>
|
||||
|
||||
<!-- Primary progress -->
|
||||
<div class="h-full bg-amber-200 absolute top-0"
|
||||
style="width: {primaryValue / max * 100}%;"
|
||||
bind:offsetWidth={progressBarOffset}>
|
||||
</div>
|
||||
<div
|
||||
class="h-full bg-amber-300 absolute top-0"
|
||||
style="width: {(primaryValue / max) * 100}%;"
|
||||
bind:offsetWidth={progressBarOffset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="absolute w-4 h-4 bg-amber-200 rounded-full transform mx-2 -translate-x-1/2 -translate-y-1/2 top-1/2 cursor-pointer
|
||||
drop-shadow-md group-hover:scale-125 group-hover:shadow-lg transition-transform duration-100"
|
||||
<div
|
||||
class="absolute w-3 h-3 bg-amber-200 rounded-full transform mx-2 -translate-x-1/2 -translate-y-1/2 top-1/2 cursor-pointer
|
||||
drop-shadow-md opacity-0 group-hover:opacity-100 transition-opacity duration-100"
|
||||
style="left: {progressBarOffset}px;"
|
||||
></div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
class="w-full h-full absolute cursor-pointer opacity-0 transform -translate-y-2"
|
||||
min={min} max={max} step={step} bind:value={primaryValue} on:mouseup on:mousedown
|
||||
on:touchstart on:touchend
|
||||
class="w-full absolute -top-0.5 cursor-pointer h-2 opacity-0"
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
bind:value={primaryValue}
|
||||
on:mouseup
|
||||
on:mousedown
|
||||
on:touchstart
|
||||
on:touchend
|
||||
/>
|
||||
</div>
|
||||
@@ -6,23 +6,33 @@
|
||||
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;
|
||||
|
||||
@@ -46,14 +56,18 @@
|
||||
let elem = document.createElement('div');
|
||||
// @ts-ignore
|
||||
if (elem.requestFullscreen) {
|
||||
reqFullscreenFunc = (elem) => { elem.requestFullscreen(); };
|
||||
reqFullscreenFunc = (elem) => {
|
||||
elem.requestFullscreen();
|
||||
};
|
||||
fullscreenChangeEvent = 'fullscreenchange';
|
||||
getFullscreenElement = () => <HTMLElement>document.fullscreenElement;
|
||||
if (document.exitFullscreen) exitFullscreen = () => document.exitFullscreen();
|
||||
// @ts-ignore
|
||||
} else if (elem.webkitRequestFullscreen) {
|
||||
reqFullscreenFunc = (elem) => {
|
||||
// @ts-ignore
|
||||
reqFullscreenFunc = (elem) => { elem.webkitRequestFullscreen(); };
|
||||
elem.webkitRequestFullscreen();
|
||||
};
|
||||
fullscreenChangeEvent = 'webkitfullscreenchange';
|
||||
// @ts-ignore
|
||||
getFullscreenElement = () => <HTMLElement>document.webkitFullscreenElement;
|
||||
@@ -61,8 +75,10 @@
|
||||
if (document.webkitExitFullscreen) exitFullscreen = () => document.webkitExitFullscreen();
|
||||
// @ts-ignore
|
||||
} else if (elem.msRequestFullscreen) {
|
||||
reqFullscreenFunc = (elem) => {
|
||||
// @ts-ignore
|
||||
reqFullscreenFunc = (elem) => { elem.msRequestFullscreen(); }
|
||||
elem.msRequestFullscreen();
|
||||
};
|
||||
fullscreenChangeEvent = 'MSFullscreenChange';
|
||||
// @ts-ignore
|
||||
getFullscreenElement = () => <HTMLElement>document.msFullscreenElement;
|
||||
@@ -70,8 +86,10 @@
|
||||
if (document.msExitFullscreen) exitFullscreen = () => document.msExitFullscreen();
|
||||
// @ts-ignore
|
||||
} else if (elem.mozRequestFullScreen) {
|
||||
reqFullscreenFunc = (elem) => {
|
||||
// @ts-ignore
|
||||
reqFullscreenFunc = (elem) => { elem.mozRequestFullScreen(); };
|
||||
elem.mozRequestFullScreen();
|
||||
};
|
||||
fullscreenChangeEvent = 'mozfullscreenchange';
|
||||
// @ts-ignore
|
||||
getFullscreenElement = () => <HTMLElement>document.mozFullScreenElement;
|
||||
@@ -97,9 +115,13 @@
|
||||
|
||||
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,
|
||||
@@ -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);
|
||||
@@ -307,78 +330,115 @@
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class={classNames("bg-black w-screen h-screen relative flex items-center justify-center", {
|
||||
class={classNames(
|
||||
'bg-black w-screen h-[100dvh] sm:h-screen relative flex items-center justify-center',
|
||||
{
|
||||
'cursor-none': !uiVisible
|
||||
})}
|
||||
}
|
||||
)}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="bg-black w-screen h-screen flex items-center justify-center"
|
||||
bind:this={videoWrapper}
|
||||
on:mousemove={() => handleUserInteraction(false)}
|
||||
on:touchend|preventDefault={() => handleUserInteraction(true)}
|
||||
on:dblclick|preventDefault={() => (fullscreen = !fullscreen)}
|
||||
on:click={() => (paused = !paused)}
|
||||
>
|
||||
<div class="bg-black w-screen h-screen flex items-center justify-center" bind:this={videoWrapper}
|
||||
on:mousemove={() => handleUserInteraction(false)} on:touchend|preventDefault={() => handleUserInteraction(true)}>
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video bind:this={video} bind:paused bind:duration on:timeupdate={() => displayedTime = (!seeking && videoLoaded) ? video.currentTime : displayedTime}
|
||||
on:progress={() => handleBuffer()} on:play={() => {
|
||||
<video
|
||||
bind:this={video}
|
||||
bind:paused
|
||||
bind:duration
|
||||
on:timeupdate={() =>
|
||||
(displayedTime = !seeking && videoLoaded ? video.currentTime : displayedTime)}
|
||||
on:progress={() => handleBuffer()}
|
||||
on:play={() => {
|
||||
if (seeking) video?.pause();
|
||||
}}
|
||||
on:loadeddata={() => {
|
||||
video.currentTime = displayedTime;
|
||||
videoLoaded = true;
|
||||
}}
|
||||
bind:volume bind:muted={mute} class="sm:w-full sm:h-full" playsinline={true} />
|
||||
bind:volume
|
||||
bind:muted={mute}
|
||||
class="sm:w-full sm:h-full"
|
||||
playsinline={true}
|
||||
/>
|
||||
|
||||
{#if uiVisible}
|
||||
<!-- Video controls -->
|
||||
<div class="absolute bottom-0 w-screen bg-gradient-to-t from-black/[.8] via-60% via-black-opacity-80 to-transparent"
|
||||
on:touchend|stopPropagation transition:fade={{duration: 100}}>
|
||||
<div class="flex flex-col items-center space-y-4 p-5 w-full">
|
||||
<div class = "flex items-center space-x-4 w-full">
|
||||
<span class="whitespace-nowrap tabular-nums">{secondsToTime(displayedTime, duration > 3600)}</span>
|
||||
<div
|
||||
class="absolute bottom-0 w-screen bg-gradient-to-t from-black/[.8] via-60% via-black-opacity-80 to-transparent"
|
||||
on:touchend|stopPropagation
|
||||
transition:fade={{ duration: 100 }}
|
||||
>
|
||||
<div class="flex flex-col items-center p-4 gap-2 w-full">
|
||||
<div class="flex items-center text-sm w-full">
|
||||
<span class="whitespace-nowrap tabular-nums"
|
||||
>{secondsToTime(displayedTime, duration > 3600)}</span
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<Slider bind:primaryValue={displayedTime}
|
||||
<Slider
|
||||
bind:primaryValue={displayedTime}
|
||||
secondaryValue={bufferedTime}
|
||||
max={duration}
|
||||
on:mousedown={onSeekStart}
|
||||
on:mouseup={onSeekEnd}
|
||||
on:touchstart={onSeekStart}
|
||||
on:touchend={onSeekEnd}/>
|
||||
on:touchend={onSeekEnd}
|
||||
/>
|
||||
</div>
|
||||
<span class="whitespace-nowrap tabular-nums">{secondsToTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-2 w-full">
|
||||
<IconButton on:click={() => paused = !paused}>
|
||||
<IconButton on:click={() => (paused = !paused)}>
|
||||
{#if (!seeking && paused) || (seeking && playerStateBeforeSeek)}
|
||||
<Play size={25} />
|
||||
<Play size={20} />
|
||||
{:else}
|
||||
<Pause size={25} />
|
||||
<Pause size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="relative">
|
||||
<ContextMenu heading="Quality" position="absolute" bottom={true} id={qualityContextMenuId}>
|
||||
<ContextMenu
|
||||
heading="Quality"
|
||||
position="absolute"
|
||||
bottom={true}
|
||||
id={qualityContextMenuId}
|
||||
>
|
||||
<svelte:fragment slot="menu">
|
||||
{#each getQualities(resolution) as quality}
|
||||
<SelectableMenuItem
|
||||
<SelectableContextMenuItem
|
||||
selected={quality.maxBitrate === currentBitrate}
|
||||
on:click={() => handleSelectQuality(quality.maxBitrate)}>
|
||||
on:click={() => handleSelectQuality(quality.maxBitrate)}
|
||||
>
|
||||
{quality.name}
|
||||
</SelectableMenuItem>
|
||||
</SelectableContextMenuItem>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
<IconButton on:click={handleQualityToggleVisibility}>
|
||||
<Gear size={25} />
|
||||
<Gear size={20} />
|
||||
</IconButton>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
|
||||
<IconButton on:click={() => {mute = !mute;}}>
|
||||
<IconButton
|
||||
on:click={() => {
|
||||
mute = !mute;
|
||||
}}
|
||||
>
|
||||
{#if volume == 0 || mute}
|
||||
<SpeakerOff size={25} />
|
||||
<SpeakerOff size={20} />
|
||||
{:else if volume < 0.25}
|
||||
<SpeakerQuiet size={25} />
|
||||
{:else if volume < 0.90}
|
||||
<SpeakerModerate size={25} />
|
||||
<SpeakerQuiet size={20} />
|
||||
{:else if volume < 0.9}
|
||||
<SpeakerModerate size={20} />
|
||||
{:else}
|
||||
<SpeakerLoud size={25} />
|
||||
<SpeakerLoud size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
|
||||
@@ -387,17 +447,17 @@
|
||||
</div>
|
||||
|
||||
{#if reqFullscreenFunc}
|
||||
<IconButton on:click={() => fullscreen = !fullscreen}>
|
||||
<IconButton on:click={() => (fullscreen = !fullscreen)}>
|
||||
{#if fullscreen}
|
||||
<ExitFullScreen size={25} />
|
||||
<ExitFullScreen size={20} />
|
||||
{:else if !fullscreen && exitFullscreen}
|
||||
<EnterFullScreen size={25} />
|
||||
<EnterFullScreen size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<!-- Edge case to allow fullscreen on iPhone -->
|
||||
{:else if video?.webkitEnterFullScreen}
|
||||
<IconButton on:click={() => video.webkitEnterFullScreen()}>
|
||||
<EnterFullScreen size={25} />
|
||||
<EnterFullScreen size={20} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -408,7 +468,7 @@
|
||||
</div>
|
||||
|
||||
{#if uiVisible}
|
||||
<div class='absolute top-4 right-8 z-50' transition:fade={{duration: 100}}>
|
||||
<div class="absolute top-4 right-8 z-50" transition:fade={{ duration: 100 }}>
|
||||
<IconButton on:click={handleClose}>
|
||||
<Cross2 size={25} />
|
||||
</IconButton>
|
||||
|
||||
Reference in New Issue
Block a user