Context menus and setting watch state

This commit is contained in:
Aleksi Lassila
2023-08-06 14:34:23 +03:00
parent 5eae3e30b8
commit ecc3d36113
16 changed files with 389 additions and 216 deletions

View File

@@ -173,3 +173,26 @@ export const reportJellyfinPlaybackStopped = (
MediaSourceId: itemId
}
});
export const setJellyfinItemWatched = (jellyfinId: string) =>
JellyfinApi.post('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: JELLYFIN_USER_ID,
itemId: jellyfinId
},
query: {
datePlayed: new Date().toISOString()
}
}
});
export const setJellyfinItemUnwatched = (jellyfinId: string) =>
JellyfinApi.del('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: JELLYFIN_USER_ID,
itemId: jellyfinId
}
}
});

View File

@@ -3,8 +3,13 @@
import { formatMinutesToTime } from '$lib/utils';
import classNames from 'classnames';
import { Clock, Star } from 'radix-icons-svelte';
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
import ContextMenuItem from '../ContextMenu/ContextMenuItem.svelte';
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { library } from '$lib/stores/library.store';
export let tmdbId: number;
export let jellyfinId: string | undefined = undefined;
export let type: 'movie' | 'series' = 'movie';
export let title: string;
export let genres: string[] = [];
@@ -17,75 +22,99 @@
export let available = true;
export let progress = 0;
export let size: 'dynamic' | 'md' | 'lg' = 'md';
export let randomProgress = false;
if (randomProgress) {
progress = Math.random() > 0.3 ? Math.random() * 100 : 0;
let watched = false;
$: watched = !available && !!jellyfinId;
function handleSetWatched() {
if (jellyfinId) {
setJellyfinItemWatched(jellyfinId).finally(() => library.refreshIn(3000));
}
}
function handleSetUnwatched() {
if (jellyfinId) {
setJellyfinItemUnwatched(jellyfinId).finally(() => library.refreshIn(3000));
}
}
</script>
<a
class={classNames(
'rounded overflow-hidden relative shadow-lg shrink-0 aspect-video selectable block hover:text-inherit',
{
'h-40': size === 'md',
'h-60': size === 'lg',
'w-full': size === 'dynamic'
}
)}
href={`/${type}/${tmdbId}`}
>
<div style={'width: ' + progress + '%'} class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]" />
<div
class="h-full w-full opacity-0 hover:opacity-100 transition-opacity flex flex-col justify-between cursor-pointer p-2 px-3 relative z-[1] peer"
style={progress > 0 ? 'padding-bottom: 0.6rem;' : ''}
<ContextMenu heading={title} disabled={!jellyfinId}>
<svelte:fragment slot="menu">
<ContextMenuItem on:click={handleSetWatched} disabled={!jellyfinId || watched}>
Mark as watched
</ContextMenuItem>
<ContextMenuItem on:click={handleSetUnwatched} disabled={!jellyfinId || !watched}>
Mark as unwatched
</ContextMenuItem>
</svelte:fragment>
<a
class={classNames(
'rounded overflow-hidden relative shadow-lg shrink-0 aspect-video selectable block hover:text-inherit',
{
'h-40': size === 'md',
'h-60': size === 'lg',
'w-full': size === 'dynamic'
}
)}
href={`/${type}/${tmdbId}`}
>
<div>
<h1 class="font-bold tracking-wider text-lg">{title}</h1>
<div class="text-xs text-zinc-300 tracking-wider font-medium">
{genres.map((genre) => genre.charAt(0).toUpperCase() + genre.slice(1)).join(', ')}
</div>
</div>
<div class="flex justify-between items-end">
{#if completionTime}
<div class="text-sm font-medium text-zinc-200 tracking-wide">
Downloaded in <b
>{formatMinutesToTime((new Date(completionTime).getTime() - Date.now()) / 1000 / 60)}</b
>
<div style={'width: ' + progress + '%'} class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]" />
<div
class="h-full w-full opacity-0 hover:opacity-100 transition-opacity flex flex-col justify-between cursor-pointer p-2 px-3 relative z-[1] peer"
style={progress > 0 ? 'padding-bottom: 0.6rem;' : ''}
>
<div>
<h1 class="font-bold tracking-wider text-lg">{title}</h1>
<div class="text-xs text-zinc-300 tracking-wider font-medium">
{genres.map((genre) => genre.charAt(0).toUpperCase() + genre.slice(1)).join(', ')}
</div>
{:else}
{#if runtimeMinutes}
<div class="flex gap-1.5 items-center">
<Clock />
</div>
<div class="flex justify-between items-end">
{#if completionTime}
<div class="text-sm font-medium text-zinc-200 tracking-wide">
Downloaded in <b
>{formatMinutesToTime(
(new Date(completionTime).getTime() - Date.now()) / 1000 / 60
)}</b
>
</div>
{:else}
{#if runtimeMinutes}
<div class="flex gap-1.5 items-center">
<Clock />
<div class="text-sm text-zinc-200">
{progress
? formatMinutesToTime(runtimeMinutes - runtimeMinutes * (progress / 100)) +
' left'
: formatMinutesToTime(runtimeMinutes)}
</div>
</div>
{/if}
{#if seasons}
<div class="text-sm text-zinc-200">
{progress
? formatMinutesToTime(runtimeMinutes - runtimeMinutes * (progress / 100)) + ' left'
: formatMinutesToTime(runtimeMinutes)}
{seasons} Season{seasons > 1 ? 's' : ''}
</div>
{/if}
<div class="flex gap-1.5 items-center">
<Star />
<div class="text-sm text-zinc-200">
{rating ? rating.toFixed(1) : 'N/A'}
</div>
</div>
{/if}
{#if seasons}
<div class="text-sm text-zinc-200">
{seasons} Season{seasons > 1 ? 's' : ''}
</div>
{/if}
<div class="flex gap-1.5 items-center">
<Star />
<div class="text-sm text-zinc-200">
{rating ? rating.toFixed(1) : 'N/A'}
</div>
</div>
{/if}
</div>
</div>
</div>
<div
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropUri + "')"}
class="absolute inset-0 bg-center bg-cover peer-hover:scale-105 transition-transform"
/>
<div
class={classNames('absolute inset-0 transition-opacity', {
'bg-darken opacity-0 peer-hover:opacity-100': available,
'bg-[#00000055] peer-hover:bg-darken': !available
})}
/>
</a>
<div
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropUri + "')"}
class="absolute inset-0 bg-center bg-cover peer-hover:scale-105 transition-transform"
/>
<div
class={classNames('absolute inset-0 transition-opacity', {
'bg-darken opacity-0 peer-hover:opacity-100': available,
'bg-[#00000055] peer-hover:bg-darken': !available
})}
/>
</a>
</ContextMenu>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { contextMenu } from './ContextMenu';
export let heading = '';
export let disabled = false;
const id = Symbol();
let menu: HTMLDivElement;
let position = { x: 0, y: 0 };
function close() {
contextMenu.hide();
}
function handleOpen(event: MouseEvent) {
if (disabled) return;
position = { x: event.clientX, y: event.clientY };
contextMenu.show(id);
}
function handleClickOutside(event: MouseEvent) {
if (!menu?.contains(event.target as Node)) {
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} />
<svelte:head>
{#if $contextMenu === id}
<style>
body {
overflow: hidden;
}
</style>
{/if}
</svelte:head>
<!-- <svelte:body bind:this={body} /> -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:contextmenu|preventDefault={handleOpen}>
<slot />
</div>
{#if $contextMenu === id}
{#key position}
<div
class="fixed z-50 px-1 py-1 bg-zinc-800 bg-opacity-50 rounded-lg backdrop-blur-xl flex flex-col"
style="left: {position.x}px; top: {position.y}px;"
bind:this={menu}
in:fly|global={{ y: 5, duration: 100, delay: 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"
>
{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,17 @@
<script lang="ts">
import classNames from 'classnames';
export let disabled = false;
</script>
<button
on:click
class={classNames(
'text-sm font-medium px-3 py-1 hover:bg-zinc-200 hover:bg-opacity-10 rounded-md text-left cursor-default',
{
'opacity-80 pointer-events-none': disabled
}
)}
>
<slot />
</button>

View File

@@ -1,12 +1,15 @@
<script lang="ts">
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import classNames from 'classnames';
import { Check, CheckCircled, DotsHorizontal, TriangleRight } from 'radix-icons-svelte';
import { Check } from 'radix-icons-svelte';
import { fade } from 'svelte/transition';
import IconButton from '../IconButton.svelte';
import { onMount } from 'svelte';
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
import PlayButton from '../PlayButton.svelte';
import ProgressBar from '../ProgressBar.svelte';
import ContextMenuItem from '../ContextMenu/ContextMenuItem.svelte';
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { playerState } from '../VideoPlayer/VideoPlayer';
import { library } from '$lib/stores/library.store';
export let backdropPath: string;
@@ -17,84 +20,125 @@
export let progress = 0;
export let watched = false;
export let handlePlay: (() => void) | undefined = undefined;
export let jellyfinId: string | undefined = undefined;
export let size: 'md' | 'dynamic' = 'md';
function handleSetWatched() {
if (!jellyfinId) return;
watched = true;
setJellyfinItemWatched(jellyfinId).finally(() => library.refreshIn(5000));
}
function handleSetUnwatched() {
if (!jellyfinId) return;
watched = false;
setJellyfinItemUnwatched(jellyfinId).finally(() => library.refreshIn(5000));
}
function handlePlay() {
if (!jellyfinId) return;
playerState.streamJellyfinId(jellyfinId);
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<button
on:click
class={classNames(
'aspect-video bg-center bg-cover bg-no-repeat rounded-lg overflow-hidden transition-all shadow-lg relative cursor-pointer selectable flex-shrink-0 placeholder-image',
{
'h-40': size === 'md',
'h-full': size === 'dynamic',
group: !!handlePlay
}
)}
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropPath + "');"}
in:fade|global={{ duration: 100, delay: 100 }}
out:fade|global={{ duration: 100 }}
>
<div
class={classNames('flex flex-col justify-between h-full group-hover:opacity-0 transition-all', {
'px-2 lg:px-3 pt-2': true,
'pb-4 lg:pb-6': progress,
'pb-2': !progress,
'bg-gradient-to-t from-darken': !!handlePlay,
'bg-darken': !handlePlay || watched
})}
>
<div class="flex justify-between items-center">
<div>
<slot name="left-top">
{#if episodeNumber}
<p class="text-xs lg:text-sm font-medium text-zinc-300">{episodeNumber}</p>
{/if}
</slot>
</div>
<div>
<slot name="right-top">
{#if runtime}
<p class="text-xs lg:text-sm font-medium text-zinc-300">
{runtime} min
</p>
{/if}
</slot>
</div>
</div>
<div class="flex items-end justify-between">
<slot name="left-bottom">
<div class="flex flex-col items-start">
{#if subtitle}
<h2 class="text-zinc-300 text-sm font-medium">{subtitle}</h2>
{/if}
{#if title}
<h1 class="font-medium text-left">
{title}
</h1>
{/if}
</div>
</slot>
<slot name="right-bottom">
{#if watched}
<Check size={20} class="opacity-80" />
{/if}
</slot>
</div>
</div>
<div class="absolute inset-0 flex items-center justify-center">
<PlayButton
on:click={handlePlay}
class="opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
{#if progress}
<div
class="absolute bottom-2 lg:bottom-3 inset-x-2 lg:inset-x-3 group-hover:opacity-0 transition-opacity"
<ContextMenu heading={subtitle}>
<svelte:fragment slot="menu">
<ContextMenuItem
on:click={handleSetWatched}
disabled={!handleSetWatched || watched || !jellyfinId}
>
<ProgressBar {progress} />
Mark as watched
</ContextMenuItem>
<ContextMenuItem
on:click={handleSetUnwatched}
disabled={!handleSetUnwatched || !watched || !jellyfinId}
>
Mark as unwatched
</ContextMenuItem>
</svelte:fragment>
<button
on:click
class={classNames(
'aspect-video bg-center bg-cover bg-no-repeat rounded-lg overflow-hidden transition-all shadow-lg relative selectable flex-shrink-0 placeholder-image',
{
'h-40': size === 'md',
'h-full': size === 'dynamic',
group: !!jellyfinId,
'cursor-default': !jellyfinId
}
)}
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropPath + "');"}
in:fade|global={{ duration: 100, delay: 100 }}
out:fade|global={{ duration: 100 }}
>
<div
class={classNames(
'flex flex-col justify-between h-full group-hover:opacity-0 transition-all',
{
'px-2 lg:px-3 pt-2': true,
'pb-4 lg:pb-6': progress,
'pb-2': !progress,
'bg-gradient-to-t from-darken': !!jellyfinId,
'bg-darken': !jellyfinId || watched
}
)}
>
<div class="flex justify-between items-center">
<div>
<slot name="left-top">
{#if episodeNumber}
<p class="text-xs lg:text-sm font-medium text-zinc-300">{episodeNumber}</p>
{/if}
</slot>
</div>
<div>
<slot name="right-top">
{#if runtime}
<p class="text-xs lg:text-sm font-medium text-zinc-300">
{runtime} min
</p>
{/if}
</slot>
</div>
</div>
<div class="flex items-end justify-between">
<slot name="left-bottom">
<div class="flex flex-col items-start">
{#if subtitle}
<h2 class="text-zinc-300 text-sm font-medium">{subtitle}</h2>
{/if}
{#if title}
<h1 class="font-medium text-left line-clamp-2">
{title}
</h1>
{/if}
</div>
</slot>
<slot name="right-bottom">
{#if watched}
<div class="flex-shrink-0">
<Check size={20} class="opacity-80" />
</div>
{/if}
</slot>
</div>
</div>
{/if}
</button>
<div class="absolute inset-0 flex items-center justify-center">
<PlayButton
on:click={handlePlay}
class="opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
{#if progress}
<div
class="absolute bottom-2 lg:bottom-3 inset-x-2 lg:inset-x-3 group-hover:opacity-0 transition-opacity"
>
<ProgressBar {progress} />
</div>
{/if}
</button>
</ContextMenu>

View File

@@ -21,7 +21,9 @@
)}
href={`/person/${tmdbId}`}
>
<div class="mx-auto rounded-full overflow-hidden flex-shrink-0 aspect-square w-full">
<div
class="mx-auto rounded-full overflow-hidden flex-shrink-0 aspect-square w-full bg-zinc-200 bg-opacity-20"
>
<div
style={"background-image: url('" + TMDB_PROFILE_SMALL + backdropUri + "')"}
class="bg-center bg-cover group-hover:scale-105 transition-transform w-full h-full"

View File

@@ -13,7 +13,7 @@
export let overview: string;
</script>
<div class="flex flex-col max-h-screen bg-black pb-4 gap-4" transition:fade>
<div class="flex flex-col max-h-screen bg-black gap-4 pb-4" transition:fade>
<div
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + backdropPath + "')"}
class="flex-shrink relative flex pt-24 aspect-video min-h-[70vh] px-4 sm:px-8 bg-center bg-cover sm:bg-fixed"
@@ -47,10 +47,12 @@
</div>
</div>
</div>
<slot name="episodes-carousel" />
<div class="py-2">
<slot name="episodes-carousel" />
</div>
</div>
<div class="flex flex-col py-4 gap-8 bg-black">
<div class="flex flex-col gap-8 bg-black">
<div
class="mx-4 sm:mx-8 py-4 sm:py-6 px-6 sm:px-10 grid grid-cols-4 sm:grid-cols-6 gap-4 sm:gap-x-8 bg-stone-900 rounded-xl"
>

View File

@@ -176,13 +176,13 @@
{/if}
{#if UIVisible}
<div
class="absolute inset-0 bg-gradient-to-t from-stone-950 via-darken via-[30%] to-darken"
class="absolute inset-0 bg-gradient-to-t from-stone-950 via-darken via-[30%] to-darken opacity-50"
in:fade={{ duration: ANIMATION_DURATION }}
out:fade={{ duration: ANIMATION_DURATION }}
/>
{:else if !UIVisible}
<div
class="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-darken"
class="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-darken opacity-50"
in:fade={{ duration: ANIMATION_DURATION }}
out:fade={{ duration: ANIMATION_DURATION }}
/>

View File

@@ -220,6 +220,7 @@ async function getLibrary(): Promise<Library> {
};
}
let delayedRefreshTimeout: NodeJS.Timeout;
function createLibraryStore() {
const { update, set, ...library } = writable<Promise<Library>>(getLibrary()); //TODO promise to undefined
@@ -232,6 +233,12 @@ function createLibraryStore() {
return {
...library,
refresh: async () => getLibrary().then((r) => set(Promise.resolve(r))),
refreshIn: async (ms: number) => {
clearTimeout(delayedRefreshTimeout);
delayedRefreshTimeout = setTimeout(() => {
getLibrary().then((r) => set(Promise.resolve(r)));
}, ms);
},
filterNotInLibrary
};
}