Merge remote-tracking branch 'upstream/master' into localization

This commit is contained in:
Axel Aguilar
2023-08-19 08:50:44 -06:00
71 changed files with 5197 additions and 1291 deletions

View File

@@ -4,8 +4,9 @@
const dispatch = createEventDispatcher();
export let size: 'md' | 'sm' | 'lg' = 'md';
export let size: 'md' | 'sm' | 'lg' | 'xs' = 'md';
export let type: 'primary' | 'secondary' | 'tertiary' = 'secondary';
export let slim = false;
export let disabled = false;
export let href: string | undefined = undefined;
@@ -13,18 +14,26 @@
let buttonStyle: string;
$: buttonStyle = classNames(
'flex items-center gap-1 rounded-xl font-medium select-none cursor-pointer selectable transition-all flex-shrink-0',
'flex items-center gap-1 font-medium select-none cursor-pointer selectable transition-all flex-shrink-0',
{
'bg-white text-zinc-900 font-extrabold backdrop-blur-lg': type === 'primary',
'bg-white text-zinc-900 font-extrabold backdrop-blur-lg rounded-xl': type === 'primary',
'hover:bg-amber-400 focus-within:bg-amber-400 hover:border-amber-400 focus-within:border-amber-400':
type === 'primary' && !disabled,
'text-zinc-200 bg-zinc-400 bg-opacity-20 backdrop-blur-lg': type === 'secondary',
'text-zinc-200 bg-zinc-600 bg-opacity-20 backdrop-blur-lg rounded-xl': type === 'secondary',
'focus-visible:bg-zinc-200 focus-visible:text-zinc-800 hover:bg-zinc-200 hover:text-zinc-800':
(type === 'secondary' || type === 'tertiary') && !disabled,
'rounded-full': type === 'tertiary',
'py-2 px-6 sm:py-3 sm:px-6': size === 'lg',
'py-2 px-6': size === 'md',
'py-1 px-3': size === 'sm',
'py-2 px-6 sm:py-3 sm:px-6': size === 'lg' && !slim,
'py-2 px-6': size === 'md' && !slim,
'py-1 px-4': size === 'sm' && !slim,
'py-1 px-4 text-sm': size === 'xs' && !slim,
'p-2 sm:p-3': size === 'lg' && slim,
'p-2': size === 'md' && slim,
'p-1': size === 'sm' && slim,
'p-1 text-sm': size === 'xs' && slim,
'opacity-50': disabled,
'cursor-pointer': !disabled
}

View File

@@ -1,25 +1,23 @@
<script lang="ts">
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { library } from '$lib/stores/library.store';
import { createLibraryItemStore } from '$lib/stores/library.store';
import type { TitleType } from '$lib/types';
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 type { TitleType } from '$lib/types';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
import { openTitleModal } from '../Modal/Modal';
import ProgressBar from '../ProgressBar.svelte';
export let tmdbId: number;
export let jellyfinId: string | undefined = undefined;
export let type: TitleType = 'movie';
export let title: string;
export let genres: string[] = [];
export let runtimeMinutes = 0;
export let seasons = 0;
export let completionTime = '';
export let backdropUri: string;
export let backdropUrl: string;
export let rating: number;
export let available = true;
@@ -27,30 +25,12 @@
export let size: 'dynamic' | 'md' | 'lg' = 'md';
export let openInModal = true;
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));
}
}
let itemStore = createLibraryItemStore(tmdbId);
</script>
<ContextMenu heading={title} disabled={!jellyfinId}>
<ContextMenu heading={title}>
<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>
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
</svelte:fragment>
<button
class={classNames(
@@ -71,7 +51,7 @@
}}
>
<div
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropUri + "')"}
style={"background-image: url('" + backdropUrl + "')"}
class="absolute inset-0 bg-center bg-cover group-hover:scale-105 group-focus-visible:scale-105 transition-transform"
/>
<div

View File

@@ -7,9 +7,10 @@ import {
} from '$lib/apis/tmdb/tmdbApi';
import type { ComponentProps } from 'svelte';
import type Card from './Card.svelte';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
export const fetchCardTmdbMovieProps = async (movie: TmdbMovie2): Promise<ComponentProps<Card>> => {
const backdropUri = getTmdbMovieBackdrop(movie.id || 0);
const backdropUri = await getTmdbMovieBackdrop(movie.id || 0);
const movieAny = movie as any;
const genres =
@@ -24,7 +25,7 @@ export const fetchCardTmdbMovieProps = async (movie: TmdbMovie2): Promise<Compon
title: movie.title || '',
genres,
runtimeMinutes: movie.runtime,
backdropUri: (await backdropUri) || '',
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
rating: movie.vote_average || 0
};
};
@@ -32,7 +33,7 @@ export const fetchCardTmdbMovieProps = async (movie: TmdbMovie2): Promise<Compon
export const fetchCardTmdbSeriesProps = async (
series: TmdbSeries2
): Promise<ComponentProps<Card>> => {
const backdropUri = getTmdbSeriesBackdrop(series.id || 0);
const backdropUri = await getTmdbSeriesBackdrop(series.id || 0);
const seriesAny = series as any;
const genres =
@@ -47,7 +48,7 @@ export const fetchCardTmdbSeriesProps = async (
title: series.name || '',
genres,
runtimeMinutes: series.episode_run_time?.[0],
backdropUri: (await backdropUri) || '',
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
rating: series.vote_average || 0,
type: 'series'
};

View File

@@ -4,22 +4,29 @@
export let heading = '';
export let disabled = false;
export let position: 'absolute' | 'fixed' = 'fixed';
let anchored = position === 'absolute';
export let bottom = false;
const id = Symbol();
export let id = Symbol();
let menu: HTMLDivElement;
let windowWidth: number;
let windowHeight: number;
let position = { x: 0, y: 0 };
let fixedPosition = { x: 0, y: 0 };
function close() {
contextMenu.hide();
}
function handleOpen(event: MouseEvent) {
if (disabled) return;
export function handleOpen(event: MouseEvent) {
if (disabled || (anchored && $contextMenu === id)) return; // Clicking button will close menu
position = { x: event.clientX, y: event.clientY };
fixedPosition = { x: event.clientX, y: event.clientY };
contextMenu.show(id);
event.preventDefault();
event.stopPropagation();
}
function handleClickOutside(event: MouseEvent) {
@@ -37,7 +44,12 @@
}
</script>
<svelte:window on:keydown={handleShortcuts} on:click={handleClickOutside} />
<svelte:window
on:keydown={handleShortcuts}
on:click={handleClickOutside}
bind:innerWidth={windowWidth}
bind:innerHeight={windowHeight}
/>
<svelte:head>
{#if $contextMenu === id}
<style>
@@ -49,24 +61,34 @@
</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}>
<div on:contextmenu|preventDefault={handleOpen} on:click={(e) => anchored && e.stopPropagation()}>
<slot />
</div>
{#if $contextMenu === id}
{#key position}
{#key fixedPosition}
<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;"
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 -
(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;' : ''}`}
bind:this={menu}
in:fly|global={{ y: 5, duration: 100, delay: 100 }}
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"
class="text-xs text-zinc-200 opacity-60 tracking-wide font-semibold px-3 py-1 line-clamp-1 text-left"
>
{heading}
</h2>

View File

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

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import ContextMenu from './ContextMenu.svelte';
import { contextMenu } from '../ContextMenu/ContextMenu';
import Button from '../Button.svelte';
import { DotsVertical } from 'radix-icons-svelte';
export let heading = '';
export let contextMenuId = Symbol();
function handleToggleVisibility() {
if ($contextMenu === contextMenuId) contextMenu.hide();
else contextMenu.show(contextMenuId);
}
</script>
<div class="relative">
<ContextMenu position="absolute" {heading} id={contextMenuId}>
<slot name="menu" slot="menu" />
<Button slim on:click={handleToggleVisibility}>
<DotsVertical size={24} />
</Button>
</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

@@ -7,9 +7,9 @@
<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',
'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-80 pointer-events-none': disabled
'opacity-75 pointer-events-none': disabled
}
)}
>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { library, type LibraryItemStore } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import type { TitleType } from '$lib/types';
import ContextMenuDivider from './ContextMenuDivider.svelte';
import ContextMenuItem from './ContextMenuItem.svelte';
export let itemStore: LibraryItemStore;
export let type: TitleType;
export let tmdbId: number;
let watched = false;
itemStore.subscribe((i) => {
if (i.item?.jellyfinItem) {
watched =
i.item.jellyfinItem.UserData?.Played !== undefined
? i.item.jellyfinItem.UserData?.Played
: watched;
}
});
function handleSetWatched() {
if ($itemStore.item?.jellyfinId) {
watched = true;
setJellyfinItemWatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
}
}
function handleSetUnwatched() {
if ($itemStore.item?.jellyfinId) {
watched = false;
setJellyfinItemUnwatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
}
}
function handleOpenInJellyfin() {
window.open(
$settings.jellyfin.baseUrl +
'/web/index.html#!/details?id=' +
$itemStore.item?.jellyfinItem?.Id
);
}
</script>
{#if $itemStore.item}
<ContextMenuItem on:click={handleSetWatched} disabled={!$itemStore.item?.jellyfinId || watched}>
Mark as watched
</ContextMenuItem>
<ContextMenuItem
on:click={handleSetUnwatched}
disabled={!$itemStore.item?.jellyfinId || !watched}
>
Mark as unwatched
</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem disabled={!$itemStore.item.jellyfinItem} on:click={handleOpenInJellyfin}>
Open in Jellyfin
</ContextMenuItem>
{#if $itemStore.item.type === 'movie'}
<ContextMenuItem
disabled={!$itemStore.item.radarrMovie}
on:click={() =>
window.open($settings.radarr.baseUrl + '/movie/' + $itemStore.item?.radarrMovie?.tmdbId)}
>
Open in Radarr
</ContextMenuItem>
{:else}
<ContextMenuItem
disabled={!$itemStore.item.sonarrSeries}
on:click={() =>
window.open(
$settings.sonarr.baseUrl + '/series/' + $itemStore.item?.sonarrSeries?.titleSlug
)}
>
Open in Sonarr
</ContextMenuItem>
{/if}
{/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

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import classNames from 'classnames';
export let disabled = false;

View File

@@ -3,8 +3,8 @@ import { writable } from 'svelte/store';
import TitlePageModal from '../TitlePageLayout/TitlePageModal.svelte';
type ModalItem = {
id: Symbol;
group: Symbol;
id: symbol;
group: symbol;
component: ConstructorOfATypedSvelteComponent;
props: Record<string, any>;
};
@@ -14,7 +14,7 @@ function createDynamicModalStack() {
top: undefined
});
function close(symbol: Symbol) {
function close(symbol: symbol) {
store.update((s) => {
s.stack = s.stack.filter((i) => i.id !== symbol);
s.top = s.stack[s.stack.length - 1];
@@ -22,7 +22,7 @@ function createDynamicModalStack() {
});
}
function closeGroup(group: Symbol) {
function closeGroup(group: symbol) {
store.update((s) => {
s.stack = s.stack.filter((i) => i.group !== group);
s.top = s.stack[s.stack.length - 1];
@@ -33,7 +33,7 @@ function createDynamicModalStack() {
function create(
component: ConstructorOfATypedSvelteComponent,
props: Record<string, any>,
group: Symbol | undefined = undefined
group: symbol | undefined = undefined
) {
const id = Symbol();
const item = { id, component, props, group: group || id };
@@ -60,7 +60,7 @@ function createDynamicModalStack() {
export const modalStack = createDynamicModalStack();
let lastTitleModal: Symbol | undefined = undefined;
let lastTitleModal: symbol | undefined = undefined;
export function openTitleModal(tmdbId: number, type: TitleType) {
if (lastTitleModal) {
modalStack.close(lastTitleModal);

View File

@@ -10,7 +10,7 @@
import type { TitleType } from '$lib/types';
import { _ } from 'svelte-i18n';
export let modalId: Symbol;
export let modalId: symbol;
let inputValue = '';
let inputElement: HTMLInputElement;

View File

@@ -9,7 +9,7 @@
export let tmdbId: number;
export let jellyfinId: string = '';
export let type: TitleType = 'movie';
export let backdropUri: string;
export let backdropUrl: string;
export let title = '';
export let subtitle = '';
@@ -19,7 +19,7 @@
<a
href={`/${type}/${tmdbId}`}
style={"background-image: url('" + TMDB_POSTER_SMALL + backdropUri + "');"}
style={"background-image: url('" + backdropUrl + "');"}
class="relative flex shadow-lg rounded-lg aspect-[2/3] bg-center bg-cover w-44 selectable group hover:text-inherit flex-shrink-0"
>
<div

View File

@@ -6,8 +6,8 @@
import ModalHeader from '../Modal/ModalHeader.svelte';
import RequestModal from './RequestModal.svelte';
export let modalId: Symbol;
export let groupId: Symbol;
export let modalId: symbol;
export let groupId: symbol;
export let sonarrId: number;
export let seasonNumber: number;
@@ -22,8 +22,8 @@
modalStack.create(
RequestModal,
{
episode,
sonarrId,
sonarrEpisodeId: episode.id,
// sonarrId,
groupId
},
groupId

View File

@@ -16,10 +16,11 @@
const dispatch = createEventDispatcher();
export let modalId: Symbol;
export let groupId: Symbol | undefined = undefined;
export let modalId: symbol;
export let groupId: symbol | undefined = undefined;
export let title = 'Releases';
export let radarrId: number | undefined = undefined;
export let sonarrEpisodeId: number | undefined = undefined;
export let seasonPack: { sonarrId: number; seasonNumber: number } | undefined = undefined;
@@ -66,21 +67,21 @@
};
}
function handleDownload(guid: string) {
function handleDownload(guid: string, indexerId: number) {
downloadFetchingGuid = guid;
if (radarrId) {
downloadRadarrMovie(guid).then((res) => {
downloadRadarrMovie(guid, indexerId).then((ok) => {
dispatch('download');
downloadFetchingGuid = undefined;
if (res.response?.ok) {
if (ok) {
downloadingGuid = guid;
}
});
} else {
downloadSonarrEpisode(guid).then((res) => {
downloadSonarrEpisode(guid, indexerId).then((ok) => {
dispatch('download');
downloadFetchingGuid = undefined;
if (res.response?.ok) {
if (ok) {
downloadingGuid = guid;
}
});
@@ -130,7 +131,10 @@
<div class="text-zinc-400">{formatSize(release?.size || 0)}</div>
{#if release.guid !== downloadingGuid}
<IconButton
on:click={() => release.guid && handleDownload(release.guid)}
on:click={() =>
release.guid &&
release.indexerId &&
handleDownload(release.guid, release.indexerId)}
disabled={downloadFetchingGuid === release.guid}
>
<Plus size={20} />

View File

@@ -8,7 +8,7 @@
import EpisodeSelectModal from './EpisodeSelectModal.svelte';
import RequestModal from './RequestModal.svelte';
export let modalId: Symbol;
export let modalId: symbol;
export let sonarrId: number;
export let seasons: number;
export let heading = 'Seasons';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import { getDiskSpace } from '$lib/apis/radarr/radarrApi';
import { library } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { formatSize } from '$lib/utils.js';
import RadarrIcon from '../svgs/RadarrIcon.svelte';
import StatsContainer from './StatsContainer.svelte';
@@ -21,7 +21,9 @@
);
const diskSpaceInfo =
(await discSpacePromise).find((disk) => disk.path === '/') || (await discSpacePromise)[0];
(await discSpacePromise).find((disk) => disk.path === '/') ||
(await discSpacePromise)[0] ||
undefined;
const spaceOccupied = availableMovies.reduce(
(acc, movie) => acc + (movie.radarrMovie?.sizeOnDisk || 0),
@@ -30,9 +32,9 @@
return {
moviesCount: availableMovies.length,
spaceLeft: diskSpaceInfo.freeSpace || 0,
spaceLeft: diskSpaceInfo?.freeSpace || 0,
spaceOccupied,
spaceTotal: diskSpaceInfo.totalSpace || 0
spaceTotal: diskSpaceInfo?.totalSpace || 0
};
}
</script>
@@ -44,7 +46,7 @@
{large}
title="Radarr"
subtitle="Movies Provider"
href={env.PUBLIC_RADARR_BASE_URL}
href={$settings.radarr.baseUrl || '#'}
stats={[
{ title: 'Movies', value: String(moviesCount) },
{ title: 'Space Taken', value: formatSize(spaceOccupied) },

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import { formatSize } from '$lib/utils.js';
import { onMount } from 'svelte';
import StatsPlaceholder from './StatsPlaceholder.svelte';
import StatsContainer from './StatsContainer.svelte';
import SonarrIcon from '../svgs/SonarrIcon.svelte';
import { env } from '$env/dynamic/public';
import { getDiskSpace } from '$lib/apis/sonarr/sonarrApi';
import { library } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { formatSize } from '$lib/utils.js';
import SonarrIcon from '../svgs/SonarrIcon.svelte';
import StatsContainer from './StatsContainer.svelte';
import StatsPlaceholder from './StatsPlaceholder.svelte';
export let large = false;
@@ -18,7 +17,9 @@
);
const diskSpaceInfo =
(await discSpacePromise).find((disk) => disk.path === '/') || (await discSpacePromise)[0];
(await discSpacePromise).find((disk) => disk.path === '/') ||
(await discSpacePromise)[0] ||
undefined;
const spaceOccupied = availableSeries.reduce(
(acc, series) => acc + (series.sonarrSeries?.statistics?.sizeOnDisk || 0),
@@ -32,9 +33,9 @@
return {
episodesCount,
spaceLeft: diskSpaceInfo.freeSpace || 0,
spaceLeft: diskSpaceInfo?.freeSpace || 0,
spaceOccupied,
spaceTotal: diskSpaceInfo.totalSpace || 0
spaceTotal: diskSpaceInfo?.totalSpace || 0
};
}
</script>
@@ -46,7 +47,7 @@
{large}
title="Sonarr"
subtitle="Shows Provider"
href={env.PUBLIC_SONARR_BASE_URL}
href={$settings.sonarr.baseUrl || '#'}
stats={[
{ title: 'Episodes', value: String(episodesCount) },
{ title: 'Space Taken', value: formatSize(spaceOccupied) },

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { LibraryItemStore } from '$lib/stores/library.store';
import type { TitleType } from '$lib/types';
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
export let title = '';
export let itemStore: LibraryItemStore;
export let type: TitleType;
export let tmdbId: number;
</script>
<ContextMenuButton heading={$itemStore.loading ? 'Loading...' : title}>
<svelte:fragment slot="menu">
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
</svelte:fragment>
</ContextMenuButton>

View File

@@ -67,7 +67,7 @@
})}
>
<div
class={classNames('flex-1 relative flex pt-24 px-4 sm:px-8 pb-6', {
class={classNames('flex-1 relative flex pt-24 px-2 sm:px-4 lg:px-8 pb-6', {
'min-h-[60vh]': isModal
})}
bind:clientHeight={topHeight}
@@ -79,7 +79,7 @@
</IconButton>
</a>
<div class="absolute top-8 left-4 sm:left-8 z-10">
<button class="flex items-center sm:hidden" on:click={handleCloseModal}>
<button class="flex items-center sm:hidden font-medium" on:click={handleCloseModal}>
<ChevronLeft size={20} />
Back
</button>
@@ -127,7 +127,7 @@
</div>
<div
class={classNames('flex flex-col gap-6 bg-stone-950 px-2 sm:px-4 lg:px-8 pb-6 relative z-[1]', {
class={classNames('flex flex-col gap-6 bg-stone-950 px-2 sm:px-4 lg:px-8 pb-6 relative', {
'2xl:px-0': !isModal
})}
>

View File

@@ -7,7 +7,7 @@
export let tmdbId: number;
export let type: TitleType;
export let modalId: Symbol;
export let modalId: symbol;
function handleCloseModal() {
modalStack.close(modalId);

View File

@@ -14,7 +14,7 @@
const TRAILER_TIMEOUT = 3000;
const TRAILER_LOAD_TIME = 1000;
const ANIMATION_DURATION = 150;
const ANIMATION_DURATION = $settings.animationDuration;
export let tmdbId: number;
export let type: TitleType;
@@ -52,13 +52,15 @@
trailerVisible = false;
UIVisible = true;
timeout = setTimeout(() => {
trailerMounted = true;
if ($settings.autoplayTrailers) {
timeout = setTimeout(() => {
trailerVisible = true;
}, TRAILER_LOAD_TIME);
}, TRAILER_TIMEOUT - TRAILER_LOAD_TIME);
trailerMounted = true; // Mount the trailer
timeout = setTimeout(() => {
trailerVisible = true;
}, TRAILER_LOAD_TIME);
}, TRAILER_TIMEOUT - TRAILER_LOAD_TIME);
}
}
onMount(() => {

View File

@@ -1,28 +1,36 @@
<script lang="ts">
import { skippedVersion } from '$lib/localstorage';
import axios from 'axios';
import IconButton from './IconButton.svelte';
import { Cross2 } from 'radix-icons-svelte';
import { log } from '$lib/utils';
import { version } from '$app/environment';
import { createLocalStorageStore } from '$lib/stores/localstorage.store';
import { Cross2 } from 'radix-icons-svelte';
import IconButton from './IconButton.svelte';
import axios from 'axios';
import Button from './Button.svelte';
let visible = true;
function fetchLatestVersion() {
const skippedVersion = createLocalStorageStore<string>('skipped-version');
async function fetchLatestVersion() {
return axios
.get('https://api.github.com/repos/aleksilassila/reiverr/tags')
.then((res) => res.data?.[0]?.name)
.then(log);
.then((res) => res.data?.[0]?.name);
}
</script>
{#await fetchLatestVersion() then latestVersion}
{#if latestVersion !== `v${version}` && latestVersion !== $skippedVersion && visible}
<div class="fixed inset-x-0 bottom-0 p-2 flex items-center justify-center z-20 bg-stone-800">
<a href="https://github.com/aleksilassila/reiverr">New version is available!</a>
<IconButton on:click={() => (visible = false)} class="absolute right-8 inset-y-0">
<Cross2 size={20} />
</IconButton>
<div
class="fixed inset-x-0 bottom-0 p-3 flex items-center justify-center z-20 bg-stone-800 text-sm"
>
<a href="https://github.com/aleksilassila/reiverr">{latestVersion} is now available!</a>
<div class="absolute right-4 inset-y-0 flex items-center gap-2">
<Button type="tertiary" size="xs" on:click={() => skippedVersion.set(latestVersion)}>
Skip this version
</Button>
<IconButton on:click={() => (visible = false)}>
<Cross2 size={20} />
</IconButton>
</div>
</div>
{/if}
{/await}

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

@@ -1,6 +1,6 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import {
delteActiveEncoding as deleteActiveEncoding,
getJellyfinItem,
getJellyfinPlaybackInfo,
reportJellyfinPlaybackProgress,
@@ -8,73 +8,233 @@
reportJellyfinPlaybackStopped
} from '$lib/apis/jellyfin/jellyfinApi';
import getDeviceProfile from '$lib/apis/jellyfin/playback-profiles';
import { getQualities } from '$lib/apis/jellyfin/qualities';
import { settings } from '$lib/stores/settings.store';
import classNames from 'classnames';
import Hls from 'hls.js';
import { Cross2 } from 'radix-icons-svelte';
import { onDestroy } from 'svelte';
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 ContextMenu from '../ContextMenu/ContextMenu.svelte';
import SelectableContextMenuItem from '../ContextMenu/SelectableContextMenuItem.svelte';
import IconButton from '../IconButton.svelte';
import { playerState } from './VideoPlayer';
import { modalStack } from '../Modal/Modal';
import Slider from './Slider.svelte';
import { playerState } from './VideoPlayer';
export let modalId: Symbol;
export let modalId: symbol;
let uiVisible = true;
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;
const fetchPlaybackInfo = (itemId: string) =>
// 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;
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 || 0
).then(async ({ playbackUri, playSessionId: sessionId, mediaSourceId, directPlay }) => {
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
? `http://jellyfin.home/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
? `${$settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
: '';
videoLoaded = false;
if (!directPlay) {
if (!Hls.isSupported()) {
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource($settings.jellyfin.baseUrl + playbackUri);
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.
*/
video.src = $settings.jellyfin.baseUrl + playbackUri;
} else {
throw new Error('HLS is not supported');
}
const hls = new Hls();
hls.loadSource(env.PUBLIC_JELLYFIN_URL + playbackUri);
hls.attachMedia(video);
} else {
video.src = env.PUBLIC_JELLYFIN_URL + playbackUri;
video.src = $settings.jellyfin.baseUrl + playbackUri;
}
resolution = item?.Height || 1080;
currentBitrate = maxBitrate || getQualities(resolution)[0].maxBitrate;
if (item?.UserData?.PlaybackPositionTicks) {
video.currentTime = item?.UserData?.PlaybackPositionTicks / 10_000_000;
displayedTime = item?.UserData?.PlaybackPositionTicks / 10_000_000;
}
video.play().then(() => video.requestFullscreen());
if (mediaSourceId) await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId);
progressInterval = setInterval(() => {
video && video.readyState === 4 && video?.currentTime > 0 && sessionId && itemId;
reportJellyfinPlaybackProgress(
// 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();
@@ -83,38 +243,235 @@
modalStack.close(modalId);
}
function handleMouseMove() {
// uiVisible = true;
// clearTimeout(mouseMovementTimeout);
// mouseMovementTimeout = setTimeout(() => {
// uiVisible = false;
// }, 2000);
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);
}
}
onDestroy(() => clearInterval(progressInterval));
function handleQualityToggleVisibility() {
if ($contextMenu === qualityContextMenuId) contextMenu.hide();
else contextMenu.show(qualityContextMenuId);
}
async function handleSelectQuality(bitrate: number) {
if (!$playerState.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?.($playerState.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;
}
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?.();
});
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="bg-black w-screen h-screen relative flex items-center justify-center"
on:mousemove={handleMouseMove}
class={classNames(
'bg-black w-screen h-[100dvh] sm:h-screen relative flex items-center justify-center',
{
'cursor-none': !uiVisible
}
)}
>
<!-- svelte-ignore a11y-media-has-caption -->
<video controls bind:this={video} class="sm:w-full sm:h-full" />
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames('absolute top-4 right-8 transition-opacity z-50', {
'opacity-0': !uiVisible,
'opacity-100': uiVisible
})}
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)}
>
<IconButton on:click={handleClose}>
<Cross2 size={25} />
</IconButton>
<!-- 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}
/>
{#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">
<div class="relative">
<ContextMenu
heading="Quality"
position="absolute"
bottom={true}
id={qualityContextMenuId}
>
<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 on:click={handleQualityToggleVisibility}>
<Gear size={20} />
</IconButton>
</ContextMenu>
</div>
<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>
{#if reqFullscreenFunc}
<IconButton on:click={() => (fullscreen = !fullscreen)}>
{#if fullscreen}
<ExitFullScreen size={20} />
{:else if !fullscreen && exitFullscreen}
<EnterFullScreen size={20} />
{/if}
</IconButton>
<!-- Edge case to allow fullscreen on iPhone -->
{:else if video?.webkitEnterFullScreen}
<IconButton on:click={() => video.webkitEnterFullScreen()}>
<EnterFullScreen size={20} />
</IconButton>
{/if}
</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

@@ -7,7 +7,7 @@
<div class="overflow-hidden w-full h-full">
<div class="youtube-container scale-[150%] h-full w-full">
<iframe
src={'https://www.youtube.com/embed/' +
src={'https://www.youtube-nocookie.com/embed/' +
videoId +
'?autoplay=1&mute=1&loop=1&controls=0&modestbranding=1&playsinline=1&rel=0&enablejsapi=1'}
title="YouTube video player"

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import classNames from 'classnames';
import { Update } from 'radix-icons-svelte';
export let type: 'base' | 'success' | 'error' = 'base';
export let loading = false;
export let disabled = false;
</script>
<button
on:click
class={classNames(
'p-1.5 px-4 text-sm text-zinc-200 rounded-lg border',
'hover:bg-opacity-30 transition-colors',
'flex items-center gap-2',
{
'bg-green-500 bg-opacity-20 text-green-200 border-green-900': type === 'success',
'bg-red-500 bg-opacity-20 text-red-200 border-red-900': type === 'error',
'bg-zinc-600 border-zinc-800 bg-opacity-20': type === 'base',
'cursor-not-allowed opacity-75 pointer-events-none': disabled || loading
},
$$restProps.class
)}
>
{#if loading}
<Update class="animate-spin" size={14} />
{/if}
<slot />
</button>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import classNames from 'classnames';
export let type: 'text' | 'number' = 'text';
export let value: any = type === 'text' ? '' : 0;
export let placeholder = '';
const baseStyles =
'appearance-none p-1 px-3 selectable border border-zinc-800 rounded-lg bg-zinc-600 bg-opacity-20 text-zinc-200 placeholder:text-zinc-700';
</script>
<div class="relative">
{#if type === 'text'}
<input type="text" {placeholder} bind:value class={classNames(baseStyles, $$restProps.class)} />
{:else if type === 'number'}
<input
type="number"
{placeholder}
bind:value
class={classNames(baseStyles, 'w-28', $$restProps.class)}
/>
{/if}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import classNames from 'classnames';
import { CaretDown } from 'radix-icons-svelte';
export let value: any = '';
export let disabled = false;
export let loading = false;
</script>
<div
class={classNames(
'relative w-max min-w-[8rem] h-min bg-zinc-600 bg-opacity-20 rounded-lg overflow-hidden',
{
'opacity-50': disabled,
'animate-pulse pointer-events-none': loading
}
)}
>
<select
on:change
bind:value
class={classNames(
'relative appearance-none p-1 pl-3 pr-8 selectable border border-zinc-800 bg-transparent rounded-lg w-full z-[1]',
{
'cursor-not-allowed pointer-events-none': disabled
}
)}
>
<slot />
</select>
<div class="absolute inset-y-0 right-2 flex items-center justify-center">
<CaretDown size={20} />
</div>
</div>

View File

@@ -0,0 +1,11 @@
<script>
export let checked = false;
</script>
<label class="relative inline-flex items-center cursor-pointer w-min h-min">
<input type="checkbox" bind:checked class="sr-only peer" />
<div
class="w-11 h-6 rounded-full peer bg-zinc-600 bg-opacity-20 peer-checked:bg-amber-200 peer-checked:bg-opacity-30 peer-selectable
after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px]"
/>
</label>