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.
|
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
|
# Additional Screenshots
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -9,4 +9,4 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
target: production
|
target: production
|
||||||
ports:
|
ports:
|
||||||
- 9494:3000
|
- 9494:9494
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const getJellyfinPlaybackInfo = async (
|
|||||||
userId,
|
userId,
|
||||||
startTimeTicks,
|
startTimeTicks,
|
||||||
autoOpenLiveStream: true,
|
autoOpenLiveStream: true,
|
||||||
maxStreamingBitrate: maxStreamingBitrate
|
maxStreamingBitrate
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
@@ -230,9 +230,7 @@ export const reportJellyfinPlaybackStopped = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const delteActiveEncoding = (
|
export const delteActiveEncoding = (playSessionId: string) =>
|
||||||
playSessionId: string
|
|
||||||
) =>
|
|
||||||
JellyfinApi?.del('/Videos/ActiveEncodings', {
|
JellyfinApi?.del('/Videos/ActiveEncodings', {
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -10,55 +10,55 @@ export function getQualities(resolution : number) {
|
|||||||
// have a resolution of 1080p, but the format isn't 16:9,
|
// have a resolution of 1080p, but the format isn't 16:9,
|
||||||
// so the height is less than 1080, so we detect as 1080p
|
// so the height is less than 1080, so we detect as 1080p
|
||||||
// anything higher than 720p, and so on for the other.
|
// anything higher than 720p, and so on for the other.
|
||||||
let data = [
|
const data = [
|
||||||
{
|
{
|
||||||
name: "4K - 120 Mbps",
|
name: '4K - 120 Mbps',
|
||||||
maxBitrate: 120000000,
|
maxBitrate: 120000000,
|
||||||
minResolution: 1080 + 1
|
minResolution: 1080 + 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "4K - 80 Mbps",
|
name: '4K - 80 Mbps',
|
||||||
maxBitrate: 80000000,
|
maxBitrate: 80000000,
|
||||||
minResolution: 1080 + 1
|
minResolution: 1080 + 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "1080p - 40 Mbps",
|
name: '1080p - 40 Mbps',
|
||||||
maxBitrate: 40000000,
|
maxBitrate: 40000000,
|
||||||
minResolution: 720 + 1
|
minResolution: 720 + 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "1080p - 10 Mbps",
|
name: '1080p - 10 Mbps',
|
||||||
maxBitrate: 10000000,
|
maxBitrate: 10000000,
|
||||||
minResolution: 720 + 1
|
minResolution: 720 + 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "720p - 8 Mbps",
|
name: '720p - 8 Mbps',
|
||||||
maxBitrate: 8000000,
|
maxBitrate: 8000000,
|
||||||
minResolution: 480 + 1
|
minResolution: 480 + 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "720p - 4 Mbps",
|
name: '720p - 4 Mbps',
|
||||||
maxBitrate: 4000000,
|
maxBitrate: 4000000,
|
||||||
minResolution: 480 + 1
|
minResolution: 480 + 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "480p - 3 Mbps",
|
name: '480p - 3 Mbps',
|
||||||
maxBitrate: 3000000,
|
maxBitrate: 3000000,
|
||||||
minResolution: 360 + 1
|
minResolution: 360 + 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "480p - 720 Kbps",
|
name: '480p - 720 Kbps',
|
||||||
maxBitrate: 720000,
|
maxBitrate: 720000,
|
||||||
minResolution: 360 + 1
|
minResolution: 360 + 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "360p - 420 Kbps",
|
name: '360p - 420 Kbps',
|
||||||
maxBitrate: 420000,
|
maxBitrate: 420000,
|
||||||
minResolution: 0
|
minResolution: 0
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
|
|
||||||
return data.filter((quality) => {
|
return data.filter((quality) => {
|
||||||
return quality.minResolution <= resolution
|
return quality.minResolution <= resolution;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,10 @@
|
|||||||
style={position === 'fixed'
|
style={position === 'fixed'
|
||||||
? `left: ${
|
? `left: ${
|
||||||
fixedPosition.x - (fixedPosition.x > windowWidth / 2 ? menu?.clientWidth : 0)
|
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
|
: menu?.getBoundingClientRect()?.left > windowWidth / 2
|
||||||
? `right: 0;${bottom ? 'bottom: 40px;' : ''}`
|
? `right: 0;${bottom ? 'bottom: 40px;' : ''}`
|
||||||
: `left: 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;
|
let progressBarOffset = 0;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-2 relative group">
|
<div class="h-1 relative group">
|
||||||
<!-- 0.54 em is half the width of the input thumb size -->
|
<div class="h-full relative px-[0.5rem]">
|
||||||
<div class="h-full relative px-[0.54em]">
|
<div class="h-full bg-zinc-200 bg-opacity-20 rounded-full overflow-hidden relative">
|
||||||
<div class="h-full bg-gray-300 rounded-full overflow-hidden relative">
|
|
||||||
<!-- Secondary progress -->
|
<!-- Secondary progress -->
|
||||||
<div class="h-full bg-gray-400 absolute top-0"
|
<div
|
||||||
style="width: {secondaryValue / max * 100}%;">
|
class="h-full bg-zinc-200 bg-opacity-20 absolute top-0"
|
||||||
</div>
|
style="width: {(secondaryValue / max) * 100}%;"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Primary progress -->
|
<!-- Primary progress -->
|
||||||
<div class="h-full bg-amber-200 absolute top-0"
|
<div
|
||||||
style="width: {primaryValue / max * 100}%;"
|
class="h-full bg-amber-300 absolute top-0"
|
||||||
bind:offsetWidth={progressBarOffset}>
|
style="width: {(primaryValue / max) * 100}%;"
|
||||||
</div>
|
bind:offsetWidth={progressBarOffset}
|
||||||
|
/>
|
||||||
</div>
|
</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
|
<div
|
||||||
drop-shadow-md group-hover:scale-125 group-hover:shadow-lg transition-transform duration-100"
|
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;"
|
style="left: {progressBarOffset}px;"
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
class="w-full h-full absolute cursor-pointer opacity-0 transform -translate-y-2"
|
class="w-full absolute -top-0.5 cursor-pointer h-2 opacity-0"
|
||||||
min={min} max={max} step={step} bind:value={primaryValue} on:mouseup on:mousedown
|
{min}
|
||||||
on:touchstart on:touchend
|
{max}
|
||||||
|
{step}
|
||||||
|
bind:value={primaryValue}
|
||||||
|
on:mouseup
|
||||||
|
on:mousedown
|
||||||
|
on:touchstart
|
||||||
|
on:touchend
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -6,23 +6,33 @@
|
|||||||
reportJellyfinPlaybackProgress,
|
reportJellyfinPlaybackProgress,
|
||||||
reportJellyfinPlaybackStarted,
|
reportJellyfinPlaybackStarted,
|
||||||
reportJellyfinPlaybackStopped,
|
reportJellyfinPlaybackStopped,
|
||||||
delteActiveEncoding
|
delteActiveEncoding as deleteActiveEncoding
|
||||||
} from '$lib/apis/jellyfin/jellyfinApi';
|
} from '$lib/apis/jellyfin/jellyfinApi';
|
||||||
import { getQualities } from '$lib/apis/jellyfin/qualities';
|
import { getQualities } from '$lib/apis/jellyfin/qualities';
|
||||||
import getDeviceProfile from '$lib/apis/jellyfin/playback-profiles';
|
import getDeviceProfile from '$lib/apis/jellyfin/playback-profiles';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
import { Cross2, Play, Pause, EnterFullScreen, ExitFullScreen,
|
import {
|
||||||
SpeakerLoud, SpeakerModerate, SpeakerQuiet, SpeakerOff, Gear } from 'radix-icons-svelte';
|
Cross2,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
EnterFullScreen,
|
||||||
|
ExitFullScreen,
|
||||||
|
SpeakerLoud,
|
||||||
|
SpeakerModerate,
|
||||||
|
SpeakerQuiet,
|
||||||
|
SpeakerOff,
|
||||||
|
Gear
|
||||||
|
} from 'radix-icons-svelte';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import IconButton from '../IconButton.svelte';
|
import IconButton from '../IconButton.svelte';
|
||||||
import { playerState } from './VideoPlayer';
|
import { playerState } from './VideoPlayer';
|
||||||
import { modalStack } from '../Modal/Modal';
|
import { modalStack } from '../Modal/Modal';
|
||||||
import { JELLYFIN_BASE_URL } from '$lib/constants';
|
import { JELLYFIN_BASE_URL } from '$lib/constants';
|
||||||
import { contextMenu } from '../ContextMenu/ContextMenu';
|
import { contextMenu } from '../ContextMenu/ContextMenu';
|
||||||
import Slider from './Slider.svelte'
|
import Slider from './Slider.svelte';
|
||||||
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
|
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
|
||||||
import SelectableMenuItem from '../ContextMenu/SelectableMenuItem.svelte';
|
import SelectableContextMenuItem from '../ContextMenu/SelectableContextMenuItem.svelte';
|
||||||
|
|
||||||
export let modalId: symbol;
|
export let modalId: symbol;
|
||||||
|
|
||||||
@@ -46,14 +56,18 @@
|
|||||||
let elem = document.createElement('div');
|
let elem = document.createElement('div');
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (elem.requestFullscreen) {
|
if (elem.requestFullscreen) {
|
||||||
reqFullscreenFunc = (elem) => { elem.requestFullscreen(); };
|
reqFullscreenFunc = (elem) => {
|
||||||
|
elem.requestFullscreen();
|
||||||
|
};
|
||||||
fullscreenChangeEvent = 'fullscreenchange';
|
fullscreenChangeEvent = 'fullscreenchange';
|
||||||
getFullscreenElement = () => <HTMLElement>document.fullscreenElement;
|
getFullscreenElement = () => <HTMLElement>document.fullscreenElement;
|
||||||
if (document.exitFullscreen) exitFullscreen = () => document.exitFullscreen();
|
if (document.exitFullscreen) exitFullscreen = () => document.exitFullscreen();
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
} else if (elem.webkitRequestFullscreen) {
|
} else if (elem.webkitRequestFullscreen) {
|
||||||
|
reqFullscreenFunc = (elem) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
reqFullscreenFunc = (elem) => { elem.webkitRequestFullscreen(); };
|
elem.webkitRequestFullscreen();
|
||||||
|
};
|
||||||
fullscreenChangeEvent = 'webkitfullscreenchange';
|
fullscreenChangeEvent = 'webkitfullscreenchange';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
getFullscreenElement = () => <HTMLElement>document.webkitFullscreenElement;
|
getFullscreenElement = () => <HTMLElement>document.webkitFullscreenElement;
|
||||||
@@ -61,8 +75,10 @@
|
|||||||
if (document.webkitExitFullscreen) exitFullscreen = () => document.webkitExitFullscreen();
|
if (document.webkitExitFullscreen) exitFullscreen = () => document.webkitExitFullscreen();
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
} else if (elem.msRequestFullscreen) {
|
} else if (elem.msRequestFullscreen) {
|
||||||
|
reqFullscreenFunc = (elem) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
reqFullscreenFunc = (elem) => { elem.msRequestFullscreen(); }
|
elem.msRequestFullscreen();
|
||||||
|
};
|
||||||
fullscreenChangeEvent = 'MSFullscreenChange';
|
fullscreenChangeEvent = 'MSFullscreenChange';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
getFullscreenElement = () => <HTMLElement>document.msFullscreenElement;
|
getFullscreenElement = () => <HTMLElement>document.msFullscreenElement;
|
||||||
@@ -70,8 +86,10 @@
|
|||||||
if (document.msExitFullscreen) exitFullscreen = () => document.msExitFullscreen();
|
if (document.msExitFullscreen) exitFullscreen = () => document.msExitFullscreen();
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
} else if (elem.mozRequestFullScreen) {
|
} else if (elem.mozRequestFullScreen) {
|
||||||
|
reqFullscreenFunc = (elem) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
reqFullscreenFunc = (elem) => { elem.mozRequestFullScreen(); };
|
elem.mozRequestFullScreen();
|
||||||
|
};
|
||||||
fullscreenChangeEvent = 'mozfullscreenchange';
|
fullscreenChangeEvent = 'mozfullscreenchange';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
getFullscreenElement = () => <HTMLElement>document.mozFullScreenElement;
|
getFullscreenElement = () => <HTMLElement>document.mozFullScreenElement;
|
||||||
@@ -97,9 +115,13 @@
|
|||||||
|
|
||||||
let shouldCloseUi = false;
|
let shouldCloseUi = false;
|
||||||
let uiVisible = true;
|
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) =>
|
getJellyfinItem(itemId).then((item) =>
|
||||||
getJellyfinPlaybackInfo(
|
getJellyfinPlaybackInfo(
|
||||||
itemId,
|
itemId,
|
||||||
@@ -154,7 +176,8 @@
|
|||||||
|
|
||||||
// A start report should only be sent when the video starts playing,
|
// A start report should only be sent when the video starts playing,
|
||||||
// not every time a playback info request is made
|
// 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 () => {
|
reportProgress = async () => {
|
||||||
await reportJellyfinPlaybackProgress(
|
await reportJellyfinPlaybackProgress(
|
||||||
@@ -163,7 +186,7 @@
|
|||||||
video?.paused == true,
|
video?.paused == true,
|
||||||
video?.currentTime * 10_000_000
|
video?.currentTime * 10_000_000
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (progressInterval) clearInterval(progressInterval);
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
progressInterval = setInterval(() => {
|
progressInterval = setInterval(() => {
|
||||||
@@ -172,8 +195,8 @@
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
deleteEncoding = () => {
|
deleteEncoding = () => {
|
||||||
delteActiveEncoding(sessionId);
|
deleteActiveEncoding(sessionId);
|
||||||
}
|
};
|
||||||
|
|
||||||
stopCallback = () => {
|
stopCallback = () => {
|
||||||
reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000);
|
reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000);
|
||||||
@@ -307,78 +330,115 @@
|
|||||||
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div
|
<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
|
'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 -->
|
<!-- svelte-ignore a11y-media-has-caption -->
|
||||||
<video bind:this={video} bind:paused bind:duration on:timeupdate={() => displayedTime = (!seeking && videoLoaded) ? video.currentTime : displayedTime}
|
<video
|
||||||
on:progress={() => handleBuffer()} on:play={() => {
|
bind:this={video}
|
||||||
|
bind:paused
|
||||||
|
bind:duration
|
||||||
|
on:timeupdate={() =>
|
||||||
|
(displayedTime = !seeking && videoLoaded ? video.currentTime : displayedTime)}
|
||||||
|
on:progress={() => handleBuffer()}
|
||||||
|
on:play={() => {
|
||||||
if (seeking) video?.pause();
|
if (seeking) video?.pause();
|
||||||
}}
|
}}
|
||||||
on:loadeddata={() => {
|
on:loadeddata={() => {
|
||||||
video.currentTime = displayedTime;
|
video.currentTime = displayedTime;
|
||||||
videoLoaded = true;
|
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}
|
{#if uiVisible}
|
||||||
<!-- Video controls -->
|
<!-- Video controls -->
|
||||||
<div class="absolute bottom-0 w-screen bg-gradient-to-t from-black/[.8] via-60% via-black-opacity-80 to-transparent"
|
<div
|
||||||
on:touchend|stopPropagation transition:fade={{duration: 100}}>
|
class="absolute bottom-0 w-screen bg-gradient-to-t from-black/[.8] via-60% via-black-opacity-80 to-transparent"
|
||||||
<div class="flex flex-col items-center space-y-4 p-5 w-full">
|
on:touchend|stopPropagation
|
||||||
<div class = "flex items-center space-x-4 w-full">
|
transition:fade={{ duration: 100 }}
|
||||||
<span class="whitespace-nowrap tabular-nums">{secondsToTime(displayedTime, duration > 3600)}</span>
|
>
|
||||||
|
<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">
|
<div class="flex-grow">
|
||||||
<Slider bind:primaryValue={displayedTime}
|
<Slider
|
||||||
|
bind:primaryValue={displayedTime}
|
||||||
secondaryValue={bufferedTime}
|
secondaryValue={bufferedTime}
|
||||||
max={duration}
|
max={duration}
|
||||||
on:mousedown={onSeekStart}
|
on:mousedown={onSeekStart}
|
||||||
on:mouseup={onSeekEnd}
|
on:mouseup={onSeekEnd}
|
||||||
on:touchstart={onSeekStart}
|
on:touchstart={onSeekStart}
|
||||||
on:touchend={onSeekEnd}/>
|
on:touchend={onSeekEnd}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="whitespace-nowrap tabular-nums">{secondsToTime(duration)}</span>
|
<span class="whitespace-nowrap tabular-nums">{secondsToTime(duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-2 w-full">
|
<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)}
|
{#if (!seeking && paused) || (seeking && playerStateBeforeSeek)}
|
||||||
<Play size={25} />
|
<Play size={20} />
|
||||||
{:else}
|
{:else}
|
||||||
<Pause size={25} />
|
<Pause size={20} />
|
||||||
{/if}
|
{/if}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="relative">
|
<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">
|
<svelte:fragment slot="menu">
|
||||||
{#each getQualities(resolution) as quality}
|
{#each getQualities(resolution) as quality}
|
||||||
<SelectableMenuItem
|
<SelectableContextMenuItem
|
||||||
selected={quality.maxBitrate === currentBitrate}
|
selected={quality.maxBitrate === currentBitrate}
|
||||||
on:click={() => handleSelectQuality(quality.maxBitrate)}>
|
on:click={() => handleSelectQuality(quality.maxBitrate)}
|
||||||
|
>
|
||||||
{quality.name}
|
{quality.name}
|
||||||
</SelectableMenuItem>
|
</SelectableContextMenuItem>
|
||||||
{/each}
|
{/each}
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
<IconButton on:click={handleQualityToggleVisibility}>
|
<IconButton on:click={handleQualityToggleVisibility}>
|
||||||
<Gear size={25} />
|
<Gear size={20} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconButton on:click={() => {mute = !mute;}}>
|
<IconButton
|
||||||
|
on:click={() => {
|
||||||
|
mute = !mute;
|
||||||
|
}}
|
||||||
|
>
|
||||||
{#if volume == 0 || mute}
|
{#if volume == 0 || mute}
|
||||||
<SpeakerOff size={25} />
|
<SpeakerOff size={20} />
|
||||||
{:else if volume < 0.25}
|
{:else if volume < 0.25}
|
||||||
<SpeakerQuiet size={25} />
|
<SpeakerQuiet size={20} />
|
||||||
{:else if volume < 0.90}
|
{:else if volume < 0.9}
|
||||||
<SpeakerModerate size={25} />
|
<SpeakerModerate size={20} />
|
||||||
{:else}
|
{:else}
|
||||||
<SpeakerLoud size={25} />
|
<SpeakerLoud size={20} />
|
||||||
{/if}
|
{/if}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
@@ -387,17 +447,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if reqFullscreenFunc}
|
{#if reqFullscreenFunc}
|
||||||
<IconButton on:click={() => fullscreen = !fullscreen}>
|
<IconButton on:click={() => (fullscreen = !fullscreen)}>
|
||||||
{#if fullscreen}
|
{#if fullscreen}
|
||||||
<ExitFullScreen size={25} />
|
<ExitFullScreen size={20} />
|
||||||
{:else if !fullscreen && exitFullscreen}
|
{:else if !fullscreen && exitFullscreen}
|
||||||
<EnterFullScreen size={25} />
|
<EnterFullScreen size={20} />
|
||||||
{/if}
|
{/if}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<!-- Edge case to allow fullscreen on iPhone -->
|
<!-- Edge case to allow fullscreen on iPhone -->
|
||||||
{:else if video?.webkitEnterFullScreen}
|
{:else if video?.webkitEnterFullScreen}
|
||||||
<IconButton on:click={() => video.webkitEnterFullScreen()}>
|
<IconButton on:click={() => video.webkitEnterFullScreen()}>
|
||||||
<EnterFullScreen size={25} />
|
<EnterFullScreen size={20} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -408,7 +468,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if uiVisible}
|
{#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}>
|
<IconButton on:click={handleClose}>
|
||||||
<Cross2 size={25} />
|
<Cross2 size={25} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user