mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-22 00:35:12 +02:00
Merge remote-tracking branch 'upstream/master' into localization
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
25
src/lib/components/ContextMenu/ContextMenuButton.svelte
Normal file
25
src/lib/components/ContextMenu/ContextMenuButton.svelte
Normal 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>
|
||||
1
src/lib/components/ContextMenu/ContextMenuDivider.svelte
Normal file
1
src/lib/components/ContextMenu/ContextMenuDivider.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<div class="bg-zinc-200 bg-opacity-20 h-[1.5px] mx-3 my-1 rounded-full" />
|
||||
@@ -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
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let disabled = false;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
17
src/lib/components/TitlePageLayout/OpenInButton.svelte
Normal file
17
src/lib/components/TitlePageLayout/OpenInButton.svelte
Normal 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>
|
||||
@@ -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
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
47
src/lib/components/VideoPlayer/Slider.svelte
Normal file
47
src/lib/components/VideoPlayer/Slider.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
28
src/lib/components/forms/FormButton.svelte
Normal file
28
src/lib/components/forms/FormButton.svelte
Normal 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>
|
||||
23
src/lib/components/forms/Input.svelte
Normal file
23
src/lib/components/forms/Input.svelte
Normal 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>
|
||||
34
src/lib/components/forms/Select.svelte
Normal file
34
src/lib/components/forms/Select.svelte
Normal 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>
|
||||
11
src/lib/components/forms/Toggle.svelte
Normal file
11
src/lib/components/forms/Toggle.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user