Work on components, Video player and more

This commit is contained in:
Aleksi Lassila
2024-03-20 18:02:24 +02:00
parent 4f0bc1e093
commit 2dbbeb4baf
23 changed files with 1002 additions and 96 deletions

View File

@@ -47,7 +47,7 @@
<Container horizontal>
<div
class={classNames(
'flex overflow-x-scroll items-center overflow-y-visible gap-4 relative scrollbar-hide p-1',
'flex overflow-x-scroll items-center overflow-y-visible relative scrollbar-hide p-1',
scrollClass
)}
bind:this={carousel}

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { contextMenu } from './ContextMenu';
export let heading = '';
export let disabled = false;
export let position: 'absolute' | 'fixed' = 'fixed';
let anchored = position === 'absolute';
export let id = Symbol();
let menu: HTMLDivElement;
let windowWidth: number;
let windowHeight: number;
let fixedPosition = { x: 0, y: 0 };
function close() {
contextMenu.hide();
}
export function handleOpen(event: MouseEvent) {
if (disabled || (anchored && $contextMenu === id)) {
close();
return;
}
fixedPosition = { x: event.clientX, y: event.clientY };
contextMenu.show(id);
event.preventDefault();
event.stopPropagation();
}
function handleClickOutside(event: MouseEvent) {
if (!menu?.contains(event.target as Node) && $contextMenu === id) {
event.preventDefault();
event.stopPropagation();
close();
}
}
function handleShortcuts(event: KeyboardEvent) {
if (event.key === 'Escape' && $contextMenu === id) {
close();
}
}
</script>
<svelte:window
on:keydown={handleShortcuts}
on:click={handleClickOutside}
bind:innerWidth={windowWidth}
bind:innerHeight={windowHeight}
/>
<svelte:head>
{#if $contextMenu === id}
<style>
body {
overflow: hidden;
}
</style>
{/if}
</svelte:head>
<!-- <svelte:body bind:this={body} /> -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:contextmenu|preventDefault={handleOpen}
on:click={(e) => {
if (anchored) {
e.stopPropagation();
handleOpen(e);
}
}}
>
<slot />
</div>
{#if $contextMenu === id}
{#key fixedPosition}
<div
class={`${position} z-50 my-2 px-1 py-1 bg-zinc-800 bg-opacity-50 rounded-lg backdrop-blur-xl flex flex-col w-max`}
style={position === 'fixed'
? `left: ${
fixedPosition.x - (fixedPosition.x > windowWidth / 2 ? menu?.clientWidth : 0)
}px; top: ${
fixedPosition.y - (fixedPosition.y > windowHeight / 2 ? menu?.clientHeight : 0)
}px;`
: menu?.getBoundingClientRect()?.left > windowWidth / 2
? `right: 0;${fixedPosition.y > windowHeight / 2 ? 'bottom: 100%;' : ''}`
: `left: 0;${fixedPosition.y > windowHeight / 2 ? 'bottom: 100%;' : ''}`}
bind:this={menu}
in:fly|global={{ y: 5, duration: 100, delay: anchored ? 0 : 100 }}
out:fly|global={{ y: 5, duration: 100 }}
>
<slot name="title">
{#if heading}
<h2
class="text-xs text-zinc-200 opacity-60 tracking-wide font-semibold px-3 py-1 line-clamp-1 text-left"
>
{heading}
</h2>
{/if}
</slot>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="flex flex-col gap-0.5" on:click={() => close()}>
<slot name="menu" />
</div>
</div>
{/key}
{/if}

View File

@@ -0,0 +1,17 @@
import { writable } from 'svelte/store';
function createContextMenu() {
const visibleItem = writable<symbol | null>(null);
return {
subscribe: visibleItem.subscribe,
show: (item: symbol) => {
visibleItem.set(item);
},
hide: () => {
visibleItem.set(null);
}
};
}
export const contextMenu = createContextMenu();

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import ContextMenu from './ContextMenu.svelte';
export let heading = '';
</script>
<div class="relative">
<ContextMenu position="absolute" {heading}>
<slot name="menu" slot="menu" />
<slot />
</ContextMenu>
</div>

View File

@@ -0,0 +1 @@
<div class="bg-zinc-200 bg-opacity-20 h-[1.5px] mx-3 my-1 rounded-full" />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import classNames from 'classnames';
export let disabled = false;
</script>
<button
on:click
class={classNames(
'text-sm font-medium tracking-wide px-3 py-1 hover:bg-zinc-200 hover:bg-opacity-10 rounded-md text-left cursor-default',
{
'opacity-75 pointer-events-none': disabled
}
)}
>
<slot />
</button>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import {
setJellyfinItemUnwatched,
setJellyfinItemWatched,
type JellyfinItem
} from '$lib/apis/jellyfin/jellyfinApi';
import type { RadarrMovie } from '../../apis/radarr/radarrApi';
import type { SonarrSeries } from '../../apis/sonarr/sonarrApi';
import { jellyfinItemsStore } from '../../stores/data.store';
import { settings } from '../../stores/settings.store';
import type { TitleType } from '../../types';
import ContextMenuDivider from './ContextMenuDivider.svelte';
import ContextMenuItem from './ContextMenuItem.svelte';
export let jellyfinItem: JellyfinItem | undefined = undefined;
export let sonarrSeries: SonarrSeries | undefined = undefined;
export let radarrMovie: RadarrMovie | undefined = undefined;
export let type: TitleType;
export let tmdbId: number;
let watched = false;
$: watched = jellyfinItem?.UserData?.Played !== undefined ? jellyfinItem.UserData?.Played : false;
function handleSetWatched() {
if (jellyfinItem?.Id) {
watched = true;
setJellyfinItemWatched(jellyfinItem.Id).finally(() => jellyfinItemsStore.refreshIn(3000));
}
}
function handleSetUnwatched() {
if (jellyfinItem?.Id) {
watched = false;
setJellyfinItemUnwatched(jellyfinItem.Id).finally(() => jellyfinItemsStore.refreshIn(3000));
}
}
function handleOpenInJellyfin() {
window.open($settings.jellyfin.baseUrl + '/web/index.html#!/details?id=' + jellyfinItem?.Id);
}
</script>
<ContextMenuItem on:click={handleSetWatched} disabled={!jellyfinItem?.Id || watched}>
Mark as watched
</ContextMenuItem>
<ContextMenuItem on:click={handleSetUnwatched} disabled={!jellyfinItem?.Id || !watched}>
Mark as unwatched
</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem disabled={!jellyfinItem?.Id} on:click={handleOpenInJellyfin}>
Open in Jellyfin
</ContextMenuItem>
{#if type === 'movie'}
<ContextMenuItem
disabled={!radarrMovie}
on:click={() => window.open($settings.radarr.baseUrl + '/movie/' + radarrMovie?.tmdbId)}
>
Open in Radarr
</ContextMenuItem>
{:else}
<ContextMenuItem
disabled={!sonarrSeries}
on:click={() => window.open($settings.sonarr.baseUrl + '/series/' + sonarrSeries?.titleSlug)}
>
Open in Sonarr
</ContextMenuItem>
{/if}
<ContextMenuItem on:click={() => window.open(`https://www.themoviedb.org/${type}/${tmdbId}`)}>
Open in TMDB
</ContextMenuItem>

View File

@@ -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>

View File

@@ -6,6 +6,8 @@
import LazyImg from './LazyImg.svelte';
import { Star } from 'radix-icons-svelte';
import type { TitleType } from '../types';
import Container from '../../Container.svelte';
import { useNavigate } from 'svelte-navigator';
export let tmdbId: number | undefined = undefined;
export let tvdbId: number | undefined = undefined;
@@ -22,9 +24,11 @@
export let shadow = false;
export let size: 'dynamic' | 'md' | 'lg' | 'sm' = 'md';
export let orientation: 'portrait' | 'landscape' = 'landscape';
const navigate = useNavigate();
</script>
<button
<Container
on:click={() => {
if (openInModal) {
if (tmdbId) {
@@ -32,12 +36,12 @@
} else if (tvdbId) {
//openTitleModal({ type, id: tvdbId, provider: 'tvdb' });
}
} else {
window.location.href = tmdbId || tvdbId ? `/${type}/${tmdbId || tvdbId}` : '#';
} else if (tmdbId || tvdbId) {
navigate(`/${type}/${tmdbId || tvdbId}`);
}
}}
class={classNames(
'relative flex rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left',
'relative flex rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left cursor-pointer',
{
'aspect-video': orientation === 'landscape',
'aspect-[2/3]': orientation === 'portrait',
@@ -118,4 +122,4 @@
<ProgressBar {progress} />
</div>
{/if}
</button>
</Container>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
export let min = 0;
export let max = 100;
export let step = 0.01;
export let primaryValue = 0;
export let secondaryValue = 0;
let progressBarOffset = 0;
</script>
<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-zinc-200 bg-opacity-20 absolute top-0"
style="width: {(secondaryValue / max) * 100}%;"
/>
<!-- Primary progress -->
<div
class="h-full bg-amber-300 absolute top-0"
style="width: {(primaryValue / max) * 100}%;"
bind:offsetWidth={progressBarOffset}
/>
</div>
<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>
<input
type="range"
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>

View File

@@ -0,0 +1,500 @@
<script lang="ts">
import {
delteActiveEncoding as deleteActiveEncoding,
getJellyfinItem,
getJellyfinPlaybackInfo,
reportJellyfinPlaybackProgress,
reportJellyfinPlaybackStarted,
reportJellyfinPlaybackStopped
} from '../../apis/jellyfin/jellyfinApi';
import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
import { getQualities } from '../../apis/jellyfin/qualities';
import { settings } from '../../stores/settings.store';
import classNames from 'classnames';
import Hls from 'hls.js';
import {
Cross2,
EnterFullScreen,
ExitFullScreen,
Gear,
Pause,
Play,
SpeakerLoud,
SpeakerModerate,
SpeakerOff,
SpeakerQuiet
} from 'radix-icons-svelte';
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { contextMenu } from '../ContextMenu/ContextMenu';
import SelectableContextMenuItem from '../ContextMenu/SelectableContextMenuItem.svelte';
import IconButton from '../IconButton.svelte';
import Slider from './Slider.svelte';
import { linear } from 'svelte/easing';
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
import { isTizen } from '../../utils/browser-detection';
export let jellyfinId: string;
let qualityContextMenuId = Symbol();
let video: HTMLVideoElement;
let videoWrapper: HTMLDivElement;
let mouseMovementTimeout: NodeJS.Timeout;
let stopCallback: () => void;
let deleteEncoding: () => void;
let reportProgress: () => void;
let progressInterval: NodeJS.Timeout;
// These functions are different in every browser
let reqFullscreenFunc: ((elem: HTMLElement) => void) | undefined = undefined;
let exitFullscreen: (() => void) | undefined = undefined;
let fullscreenChangeEvent: string | undefined = undefined;
let getFullscreenElement: (() => HTMLElement) | undefined = undefined;
// Find the correct functions
let elem = document.createElement('div');
// @ts-ignore
if (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
elem.webkitRequestFullscreen();
};
fullscreenChangeEvent = 'webkitfullscreenchange';
// @ts-ignore
getFullscreenElement = () => <HTMLElement>document.webkitFullscreenElement;
// @ts-ignore
if (document.webkitExitFullscreen) exitFullscreen = () => document.webkitExitFullscreen();
// @ts-ignore
} else if (elem.msRequestFullscreen) {
reqFullscreenFunc = (elem) => {
// @ts-ignore
elem.msRequestFullscreen();
};
fullscreenChangeEvent = 'MSFullscreenChange';
// @ts-ignore
getFullscreenElement = () => <HTMLElement>document.msFullscreenElement;
// @ts-ignore
if (document.msExitFullscreen) exitFullscreen = () => document.msExitFullscreen();
// @ts-ignore
} else if (elem.mozRequestFullScreen) {
reqFullscreenFunc = (elem) => {
// @ts-ignore
elem.mozRequestFullScreen();
};
fullscreenChangeEvent = 'mozfullscreenchange';
// @ts-ignore
getFullscreenElement = () => <HTMLElement>document.mozFullScreenElement;
// @ts-ignore
if (document.mozCancelFullScreen) exitFullscreen = () => document.mozCancelFullScreen();
}
let paused: boolean = false;
let duration: number = 0;
let displayedTime: number = 0;
let bufferedTime: number = 0;
let videoLoaded: boolean = false;
let seeking: boolean = false;
let playerStateBeforeSeek: boolean;
let fullscreen: boolean = false;
let volume: number = 1;
let mute: boolean = false;
let resolution: number = 1080;
let currentBitrate: number = 0;
let shouldCloseUi = false;
let uiVisible = true;
$: uiVisible = !shouldCloseUi || seeking || paused || $contextMenu === qualityContextMenuId;
const fetchPlaybackInfo = (
itemId: string,
maxBitrate: number | undefined = undefined,
starting: boolean = true
) =>
getJellyfinItem(itemId).then((item) =>
getJellyfinPlaybackInfo(
itemId,
getDeviceProfile(),
item?.UserData?.PlaybackPositionTicks || Math.floor(displayedTime * 10_000_000),
maxBitrate || getQualities(item?.Height || 1080)[0].maxBitrate
).then(async (playbackInfo) => {
if (!playbackInfo) return;
const { playbackUri, playSessionId: sessionId, mediaSourceId, directPlay } = playbackInfo;
if (!playbackUri || !sessionId) {
console.log('No playback URL or session ID', playbackUri, sessionId);
return;
}
video.poster = item?.BackdropImageTags?.length
? `${$settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
: '';
videoLoaded = false;
if (!directPlay) {
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource($settings.jellyfin.baseUrl + playbackUri);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl') || isTizen()) {
/*
* 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 = $settings.jellyfin.baseUrl + playbackUri;
} else {
throw new Error('HLS is not supported');
}
} else {
video.src = $settings.jellyfin.baseUrl + playbackUri;
}
resolution = item?.Height || 1080;
currentBitrate = maxBitrate || getQualities(resolution)[0].maxBitrate;
if (item?.UserData?.PlaybackPositionTicks) {
displayedTime = item?.UserData?.PlaybackPositionTicks / 10_000_000;
}
// We should not requestFullscreen automatically, as it's not what
// the user expects. Moreover, most browsers will deny the request
// if the video takes a while to load.
// video.play().then(() => videoWrapper.requestFullscreen());
// 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);
reportProgress = async () => {
await reportJellyfinPlaybackProgress(
itemId,
sessionId,
video?.paused == true,
video?.currentTime * 10_000_000
);
};
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
video && video.readyState === 4 && video?.currentTime > 0 && sessionId && itemId;
reportProgress();
}, 5000);
deleteEncoding = () => {
deleteActiveEncoding(sessionId);
};
stopCallback = () => {
reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000);
deleteEncoding();
};
})
);
function onSeekStart() {
if (seeking) return;
playerStateBeforeSeek = paused;
seeking = true;
paused = true;
}
function onSeekEnd() {
if (!seeking) return;
paused = playerStateBeforeSeek;
seeking = false;
video.currentTime = displayedTime;
}
function handleBuffer() {
let timeRanges = video.buffered;
// 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++) {
if (timeRanges.end(i) > video.currentTime) {
bufferedTime = timeRanges.end(i);
break;
}
}
}
// function handleClose() {
// playerState.close();
// video?.pause();
// clearInterval(progressInterval);
// stopCallback?.();
// modalStack.close(modalId);
// }
function handleUserInteraction(touch: boolean = false) {
if (touch) shouldCloseUi = !shouldCloseUi;
else shouldCloseUi = false;
if (!shouldCloseUi) {
if (mouseMovementTimeout) clearTimeout(mouseMovementTimeout);
mouseMovementTimeout = setTimeout(() => {
shouldCloseUi = true;
}, 3000);
} else {
if (mouseMovementTimeout) clearTimeout(mouseMovementTimeout);
}
}
async function handleSelectQuality(bitrate: number) {
if (!jellyfinId || !video || seeking) return;
if (bitrate === currentBitrate) return;
currentBitrate = bitrate;
video.pause();
let timeBeforeLoad = video.currentTime;
let stateBeforeLoad = paused;
await reportProgress?.();
await deleteEncoding?.();
await fetchPlaybackInfo?.(jellyfinId, bitrate, false);
displayedTime = timeBeforeLoad;
paused = stateBeforeLoad;
}
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}:`;
if (minutes >= 10) str += `${minutes}:`;
else str += `0${minutes}:`;
if (secondsLeft >= 10) str += `${secondsLeft}`;
else str += `0${secondsLeft}`;
return str;
}
$: {
if (video && jellyfinId) {
if (video.src === '') fetchPlaybackInfo(jellyfinId);
paused = false;
console.log('Paused', paused);
video.play();
}
}
onMount(() => {
// Workaround because the paused state does not sync
// with the video element until a change is made
paused = false;
// if (video && $playerState.jellyfinId) {
// if (video.src === '') fetchPlaybackInfo($playerState.jellyfinId);
// }
});
onDestroy(() => {
clearInterval(progressInterval);
if (fullscreen) exitFullscreen?.();
});
$: {
if (fullscreen && !getFullscreenElement?.()) {
if (reqFullscreenFunc) reqFullscreenFunc(videoWrapper);
} else if (getFullscreenElement?.()) {
if (exitFullscreen) exitFullscreen();
}
}
// We add a listener to the fullscreen change event to update the fullscreen variable
// since it can be changed by the user by other means than the button
if (fullscreenChangeEvent) {
document.addEventListener(fullscreenChangeEvent, () => {
fullscreen = !!getFullscreenElement?.();
});
}
function handleRequestFullscreen() {
if (reqFullscreenFunc) {
fullscreen = !fullscreen;
// @ts-ignore
} else if (video?.webkitEnterFullScreen) {
// Edge case to allow fullscreen on iPhone
// @ts-ignore
video.webkitEnterFullScreen();
}
}
function handleShortcuts(event: KeyboardEvent) {
if (event.key === 'f') {
handleRequestFullscreen();
} else if (event.key === ' ') {
paused = !paused;
} else if (event.key === 'ArrowLeft') {
video.currentTime -= 10;
} else if (event.key === 'ArrowRight') {
video.currentTime += 10;
} else if (event.key === 'ArrowUp') {
volume = Math.min(volume + 0.1, 1);
} else if (event.key === 'ArrowDown') {
volume = Math.max(volume - 0.1, 0);
}
}
</script>
<svelte:window on:keydown={handleShortcuts} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!--<div-->
<!-- class={classNames(-->
<!-- 'bg-black w-screen h-[100dvh] sm:h-screen relative flex items-center justify-center',-->
<!-- {-->
<!-- 'cursor-none': !uiVisible-->
<!-- }-->
<!-- )}-->
<!-- in:fade|global={{ duration: 300, easing: linear }}-->
<!-- out:fade|global={{ duration: 200, easing: linear }}-->
<!--&gt;-->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="w-screen h-screen flex items-center justify-center"
bind:this={videoWrapper}
on:mousemove={() => handleUserInteraction(false)}
on:touchend|preventDefault={() => handleUserInteraction(true)}
in:fade|global={{ duration: 500, delay: 1200, easing: linear }}
>
<!-- 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={() => {
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}
on:dblclick|preventDefault={() => (fullscreen = !fullscreen)}
on:click={() => (paused = !paused)}
autoplay
/>
{#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 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}
secondaryValue={bufferedTime}
max={duration}
on:mousedown={onSeekStart}
on:mouseup={onSeekEnd}
on:touchstart={onSeekStart}
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)}>
{#if (!seeking && paused) || (seeking && playerStateBeforeSeek)}
<Play size={20} />
{:else}
<Pause size={20} />
{/if}
</IconButton>
<div class="flex items-center space-x-3">
<ContextMenuButton heading="Quality">
<svelte:fragment slot="menu">
{#each getQualities(resolution) as quality}
<SelectableContextMenuItem
selected={quality.maxBitrate === currentBitrate}
on:click={() => handleSelectQuality(quality.maxBitrate)}
>
{quality.name}
</SelectableContextMenuItem>
{/each}
</svelte:fragment>
<IconButton>
<Gear size={20} />
</IconButton>
</ContextMenuButton>
<IconButton
on:click={() => {
mute = !mute;
}}
>
{#if volume == 0 || mute}
<SpeakerOff size={20} />
{:else if volume < 0.25}
<SpeakerQuiet size={20} />
{:else if volume < 0.9}
<SpeakerModerate size={20} />
{:else}
<SpeakerLoud size={20} />
{/if}
</IconButton>
<div class="w-32">
<Slider bind:primaryValue={volume} secondaryValue={0} max={1} />
</div>
<IconButton on:click={handleRequestFullscreen}>
{#if fullscreen}
<ExitFullScreen size={20} />
{:else if !fullscreen && exitFullscreen}
<EnterFullScreen size={20} />
{/if}
</IconButton>
</div>
</div>
</div>
</div>
{/if}
</div>
<!--{#if uiVisible}-->
<!-- <div class="absolute top-4 right-8 z-50" transition:fade={{ duration: 100 }}>-->
<!-- <IconButton on:click={handleClose}>-->
<!-- <Cross2 size={25} />-->
<!-- </IconButton>-->
<!-- </div>-->
<!--{/if}-->
<!--</div>-->

View File

@@ -0,0 +1,25 @@
import { writable } from 'svelte/store';
import { modalStack } from '../../stores/modal.store';
import VideoPlayer from './VideoPlayer.svelte';
import { jellyfinItemsStore } from '../../stores/data.store';
const initialValue = { visible: false, jellyfinId: '' };
export type PlayerStateValue = typeof initialValue;
function createPlayerState() {
const store = writable<PlayerStateValue>(initialValue);
return {
...store,
streamJellyfinId: (id: string) => {
store.set({ visible: true, jellyfinId: id });
modalStack.create(VideoPlayer, {}); // FIXME
},
close: () => {
store.set({ visible: false, jellyfinId: '' });
jellyfinItemsStore.refresh();
}
};
}
export const playerState = createPlayerState();