mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-27 03:05:10 +02:00
refactor: remove unused components from the project, cleanup
Squashed commits: compstack purge part 1, next up modal rework purge 2, continuing with modals
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import ComponentStackContextProvider from './ComponentStackContextProvider.svelte';
|
||||
import { useComponentStack } from './component-stack.store';
|
||||
|
||||
export let componentStack = useComponentStack();
|
||||
const items = componentStack._items;
|
||||
|
||||
onDestroy(() => {
|
||||
componentStack.reset();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each $items as item, i (item.id)}
|
||||
<ComponentStackContextProvider
|
||||
{...$$restProps}
|
||||
{componentStack}
|
||||
{item}
|
||||
isTop={i === $items.length - 1}
|
||||
isHidden={item.group !== $items[$items.length - 1]?.group}
|
||||
/>
|
||||
{/each}
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
componentStackContext,
|
||||
type ComponentStackStore
|
||||
} from '$lib/stores/component-stack.store';
|
||||
import classNames from 'classnames';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import Container from '../Container.svelte';
|
||||
|
||||
const componentStackIndex = getContext<number>('component-stack-index');
|
||||
const { top, ...componentStack } = componentStackContext.getContext();
|
||||
|
||||
$: nextComponent = $componentStack[componentStackIndex + 1];
|
||||
$: component = $componentStack[componentStackIndex];
|
||||
$: hidden = $top?.group !== component?.group && $top?.id !== component?.id;
|
||||
|
||||
export let trapFocus = false;
|
||||
export let sidebar: boolean | undefined = undefined;
|
||||
export let preventScroll = false;
|
||||
|
||||
setContext('component-stack-index', componentStackIndex + 1);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if ($top?.preventScroll || preventScroll) && $top === component}
|
||||
<style>
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<Container
|
||||
disabled={hidden}
|
||||
focusOnMount={!hidden}
|
||||
{trapFocus}
|
||||
class={classNames(
|
||||
'fixed inset-0 overflow-x-hidden overflow-y-auto scrollbar-hide',
|
||||
{
|
||||
'z-[21]': sidebar === false,
|
||||
'opacity-0': hidden
|
||||
},
|
||||
$$restProps.class
|
||||
)}
|
||||
style="backface-visibility: hidden"
|
||||
>
|
||||
<slot />
|
||||
</Container>
|
||||
|
||||
{#if nextComponent}
|
||||
<svelte:component this={nextComponent.component} {...nextComponent.props} />
|
||||
{/if}
|
||||
@@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import Container from '../Container.svelte';
|
||||
import {
|
||||
componentStackContext,
|
||||
type ComponentPage,
|
||||
type ComponentStackStore
|
||||
} from './component-stack.store';
|
||||
|
||||
export let componentStack: ComponentStackStore;
|
||||
export let item: ComponentPage;
|
||||
export let isTop: boolean;
|
||||
export let isHidden: boolean;
|
||||
|
||||
componentStackContext.createContext(componentStack, item);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if item.preventScroll && isTop}
|
||||
<style>
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<Container
|
||||
disabled={isHidden}
|
||||
focusOnMount={!isHidden}
|
||||
trapFocus={false}
|
||||
class={classNames(
|
||||
'fixed inset-0 overflow-x-hidden overflow-y-auto scrollbar-hide',
|
||||
{
|
||||
'z-[21]': item.sidebar === false,
|
||||
'opacity-0': isHidden
|
||||
},
|
||||
$$restProps.class
|
||||
)}
|
||||
style="backface-visibility: hidden"
|
||||
>
|
||||
<svelte:component
|
||||
this={item.component}
|
||||
{...item.props}
|
||||
{isTop}
|
||||
{isHidden}
|
||||
{componentStack}
|
||||
page={item}
|
||||
/>
|
||||
</Container>
|
||||
@@ -1,28 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type MenuStack = ReturnType<typeof useMenuStack>;
|
||||
|
||||
export function useMenuStack() {
|
||||
type Page = {
|
||||
id: symbol;
|
||||
};
|
||||
|
||||
const pages = writable<Page[]>([]);
|
||||
|
||||
function addPage(id: symbol) {
|
||||
const page: Page = { id };
|
||||
pages.update((p) => [...p, page]);
|
||||
}
|
||||
|
||||
function removePage(id: symbol) {
|
||||
pages.update((p) => {
|
||||
return p.filter((page) => page.id !== id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: pages.subscribe,
|
||||
addPage,
|
||||
removePage
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal/Modal.svelte';
|
||||
import { hook } from '$lib/utils';
|
||||
import { createModal } from '../Modal/modal.store';
|
||||
import type { MenuStack } from './DynamicMenu';
|
||||
|
||||
export let stack: MenuStack;
|
||||
|
||||
const openModal = hook(createModal, {
|
||||
before: (args) => {
|
||||
args[1] = {
|
||||
...args[1],
|
||||
stack
|
||||
};
|
||||
|
||||
return args;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<slot {openModal} />
|
||||
</Modal>
|
||||
@@ -1,116 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Container from '$components/Container.svelte';
|
||||
import DynamicListView from '$lib/components/Menu/ListMenu.svelte';
|
||||
import { reiverrApi, user } from '$lib/stores/user.store';
|
||||
import { capitalize } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { ChevronRight } from 'radix-icons-svelte';
|
||||
import type { MediaSourceDto, ViewProviderDto } from '../../apis/reiverr/reiverr.openapi';
|
||||
import { mediaSourceContext } from '../../pages/TitlePages/media-source.context';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import ComponentStack from '../ComponentStack/ComponentStack.svelte';
|
||||
|
||||
type ViewItem = {
|
||||
label: string;
|
||||
handleClick: () => void;
|
||||
};
|
||||
|
||||
export let tmdbId: string;
|
||||
export let season: number | undefined = undefined;
|
||||
export let episode: number | undefined = undefined;
|
||||
export let playStream: (mediaSource: MediaSourceDto, streamId: string) => void;
|
||||
|
||||
const { componentStack } = mediaSourceContext.createContext({ playStream });
|
||||
|
||||
const views = getViews();
|
||||
let mediaSourceOptions: ViewItem[] | undefined = undefined;
|
||||
|
||||
async function getViews(): Promise<ViewItem[]> {
|
||||
const { viewGroups } = await reiverrApi.sources
|
||||
.getMediaSourceViewGroups({ tmdbId, season, episode })
|
||||
.then((r) => r.data);
|
||||
|
||||
const mediaSources = $user?.mediaSources;
|
||||
|
||||
if (!mediaSources) return [];
|
||||
|
||||
const views: ViewItem[] = [];
|
||||
|
||||
for (const group of viewGroups) {
|
||||
if (!group.viewProviders.length) continue;
|
||||
|
||||
views.push({
|
||||
label: group.label,
|
||||
handleClick: () => {
|
||||
const openView = (viewProvider: ViewProviderDto) => {
|
||||
const mediaSource = mediaSources.find((source) => source.id === viewProvider.sourceId);
|
||||
|
||||
if (!mediaSource) return;
|
||||
|
||||
if (viewProvider.view.type === 'list-with-details') {
|
||||
componentStack.create(DynamicListView, {
|
||||
viewBase: viewProvider.view,
|
||||
source: mediaSource,
|
||||
tmdbId,
|
||||
season,
|
||||
episode
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (group.viewProviders.length > 1) {
|
||||
mediaSourceOptions = group.viewProviders.map((viewProvider) => ({
|
||||
label: viewProvider.sourceId,
|
||||
handleClick: () => {
|
||||
mediaSourceOptions = undefined;
|
||||
openView(viewProvider);
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
const viewProvider = group.viewProviders[0];
|
||||
if (!viewProvider) return;
|
||||
openView(viewProvider);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return views;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal let:close>
|
||||
<Container
|
||||
class="h-screen py-16 px-32 bg-primary-800 space-y-8 overflow-y-auto flex flex-col"
|
||||
on:back={({ detail }) => {
|
||||
if (!$componentStack.length) {
|
||||
close();
|
||||
} else {
|
||||
componentStack.pop();
|
||||
detail.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if !$componentStack.length}
|
||||
{#await views then views}
|
||||
{#each views as view}
|
||||
<Container on:clickOrSelect={view.handleClick} let:hasFocus class="cursor-pointer">
|
||||
<span
|
||||
class={classNames('text-3xl font-semibold flex items-center', {
|
||||
'text-secondary-400': !hasFocus,
|
||||
'text-primary-100': hasFocus
|
||||
})}
|
||||
>
|
||||
{capitalize(view.label)}
|
||||
{#if hasFocus}
|
||||
<ChevronRight class="w-8 h-8 ml-4" />
|
||||
{/if}
|
||||
</span>
|
||||
</Container>
|
||||
{/each}
|
||||
{/await}
|
||||
{:else}
|
||||
<ComponentStack {componentStack} />
|
||||
{/if}
|
||||
</Container>
|
||||
</Modal>
|
||||
@@ -1,19 +1,12 @@
|
||||
import { Selectable, useRegistrar } from '$lib/selectable';
|
||||
import {
|
||||
getContext,
|
||||
hasContext,
|
||||
onDestroy,
|
||||
setContext,
|
||||
SvelteComponentTyped,
|
||||
type ComponentProps,
|
||||
type ComponentType
|
||||
} from 'svelte';
|
||||
import { onDestroy, SvelteComponentTyped, type ComponentProps, type ComponentType } from 'svelte';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import YoutubeVideo from '../VideoPlayer/YoutubeVideo.svelte';
|
||||
import TmdbVideoPlayer from '../VideoPlayer/TmdbVideoPlayer.svelte';
|
||||
import type { MediaSourceDto } from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import { getContext, hasContext, setContext } from '../StackRouter/stack-router.store';
|
||||
|
||||
export const BACKGROUND_CONTEXT_KEY = Symbol('BACKGROUND_CONTEXT_KEY');
|
||||
export const BACKGROUND_CONTEXT_KEY = 'background-context';
|
||||
|
||||
export type Background = {
|
||||
backdropUri: string;
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TextField from '../TextField.svelte';
|
||||
import { user } from '../../stores/user.store';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import SelectField from '../SelectField.svelte';
|
||||
import { jellyfinApi, type JellyfinUser } from '../../apis/jellyfin/jellyfin-api';
|
||||
import { derived, get } from 'svelte/store';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
'click-user': {
|
||||
user: JellyfinUser | undefined;
|
||||
users: JellyfinUser[];
|
||||
setJellyfinUser: typeof setJellyfinUser;
|
||||
};
|
||||
}>();
|
||||
|
||||
let baseUrl = get(user)?.settings.jellyfin.baseUrl || '';
|
||||
let apiKey = get(user)?.settings.jellyfin.apiKey || '';
|
||||
export let jellyfinUser: JellyfinUser | undefined = undefined;
|
||||
const originalBaseUrl = derived(user, (user) => user?.settings.jellyfin.baseUrl || '');
|
||||
const originalApiKey = derived(user, (user) => user?.settings.jellyfin.apiKey || '');
|
||||
const originalUserId = derived(user, (user) => user?.settings.jellyfin.userId || undefined);
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
export let jellyfinUsers: Promise<JellyfinUser[]> | undefined = undefined;
|
||||
|
||||
let stale = false;
|
||||
let error = '';
|
||||
|
||||
$: {
|
||||
jellyfinUser;
|
||||
$originalBaseUrl;
|
||||
$originalApiKey;
|
||||
$originalUserId;
|
||||
stale = getIsStale();
|
||||
}
|
||||
|
||||
handleChange();
|
||||
|
||||
function getIsStale() {
|
||||
return (
|
||||
(!!jellyfinUser?.Id || (!baseUrl && !apiKey && !jellyfinUser)) &&
|
||||
($originalBaseUrl !== baseUrl ||
|
||||
$originalApiKey !== apiKey ||
|
||||
$originalUserId !== jellyfinUser?.Id)
|
||||
);
|
||||
}
|
||||
|
||||
function handleChange() {
|
||||
clearTimeout(timeout);
|
||||
stale = false;
|
||||
error = '';
|
||||
jellyfinUsers = undefined;
|
||||
jellyfinUser = undefined;
|
||||
|
||||
if (baseUrl === '' || apiKey === '') {
|
||||
stale = getIsStale();
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrlCopy = baseUrl;
|
||||
const apiKeyCopy = apiKey;
|
||||
timeout = setTimeout(async () => {
|
||||
jellyfinUsers = jellyfinApi.getJellyfinUsers(baseUrl, apiKey);
|
||||
|
||||
const users = await jellyfinUsers;
|
||||
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
|
||||
|
||||
if (users.length) {
|
||||
jellyfinUser = users.find((u) => u.Id === get(user)?.settings.jellyfin.userId);
|
||||
// stale = !!jellyfinUser?.Id && getIsStale();
|
||||
} else {
|
||||
error = 'Could not connect';
|
||||
stale = false;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const setJellyfinUser = (u: JellyfinUser) => (jellyfinUser = u);
|
||||
|
||||
async function handleSave() {
|
||||
if (!stale) return;
|
||||
|
||||
return user.updateUser((prev) => ({
|
||||
...prev,
|
||||
settings: {
|
||||
...prev.settings,
|
||||
jellyfin: {
|
||||
...prev.settings.jellyfin,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
userId: jellyfinUser?.Id || ''
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
$: empty = !baseUrl && !apiKey && !jellyfinUser;
|
||||
$: unchanged =
|
||||
$originalBaseUrl === baseUrl &&
|
||||
$originalApiKey === apiKey &&
|
||||
$originalUserId === jellyfinUser?.Id;
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 mb-4">
|
||||
<TextField
|
||||
bind:value={baseUrl}
|
||||
isValid={jellyfinUsers?.then((u) => !!u?.length)}
|
||||
on:change={handleChange}
|
||||
>
|
||||
Base Url
|
||||
</TextField>
|
||||
<TextField
|
||||
bind:value={apiKey}
|
||||
isValid={jellyfinUsers?.then((u) => !!u?.length)}
|
||||
on:change={handleChange}
|
||||
>
|
||||
API Key
|
||||
</TextField>
|
||||
</div>
|
||||
|
||||
{#await jellyfinUsers then users}
|
||||
{#if users?.length}
|
||||
<SelectField
|
||||
value={jellyfinUser?.Name || 'Select User'}
|
||||
on:clickOrSelect={() =>
|
||||
dispatch('click-user', { user: jellyfinUser, users, setJellyfinUser })}
|
||||
class="mb-4"
|
||||
>
|
||||
User
|
||||
</SelectField>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
{#if error}
|
||||
<div class="text-red-500 mb-4">{error}</div>
|
||||
{/if}
|
||||
|
||||
<slot {handleSave} {stale} {empty} {unchanged} />
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import type { JellyfinUser } from '../../apis/jellyfin/jellyfin-api';
|
||||
import SelectItem from '../SelectItem.svelte';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
|
||||
export let users: JellyfinUser[];
|
||||
export let selectedUser: JellyfinUser | undefined;
|
||||
export let handleSelectUser: (user: JellyfinUser) => void;
|
||||
|
||||
function handleSelect(user: JellyfinUser) {
|
||||
handleSelectUser(user);
|
||||
modalStack.closeTopmost();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog>
|
||||
<h1 class="h4 mb-2">Users</h1>
|
||||
<div class="space-y-4">
|
||||
{#each users as user}
|
||||
<SelectItem
|
||||
selected={user.Id === selectedUser?.Id}
|
||||
on:clickOrSelect={() => handleSelect(user)}
|
||||
>
|
||||
{user.Name}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -1,97 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TextField from '../TextField.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { radarrApi } from '../../apis/radarr/radarr-api';
|
||||
import { user } from '../../stores/user.store';
|
||||
import { derived, get } from 'svelte/store';
|
||||
|
||||
let baseUrl = get(user)?.settings.radarr.baseUrl || '';
|
||||
let apiKey = get(user)?.settings.radarr.apiKey || '';
|
||||
const originalBaseUrl = derived(user, (user) => user?.settings.radarr.baseUrl || '');
|
||||
const originalApiKey = derived(user, (user) => user?.settings.radarr.apiKey || '');
|
||||
|
||||
let stale = false;
|
||||
let error = '';
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let healthCheck: Promise<boolean> | undefined;
|
||||
|
||||
$: {
|
||||
$originalBaseUrl;
|
||||
$originalApiKey;
|
||||
stale = getIsStale();
|
||||
}
|
||||
|
||||
handleChange();
|
||||
|
||||
function getIsStale() {
|
||||
return (
|
||||
(!!healthCheck || (!baseUrl && !apiKey)) &&
|
||||
($originalBaseUrl !== baseUrl || $originalApiKey !== apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
function handleChange() {
|
||||
clearTimeout(timeout);
|
||||
stale = false;
|
||||
error = '';
|
||||
healthCheck = undefined;
|
||||
|
||||
if (baseUrl === '' || apiKey === '') {
|
||||
stale = getIsStale();
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrlCopy = baseUrl;
|
||||
const apiKeyCopy = apiKey;
|
||||
timeout = setTimeout(async () => {
|
||||
const p = radarrApi.getHealth(baseUrlCopy, apiKeyCopy);
|
||||
healthCheck = p.then((res) => res.status === 200);
|
||||
|
||||
const res = await p;
|
||||
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
|
||||
|
||||
if (res.status !== 200) {
|
||||
error =
|
||||
res.status === 404
|
||||
? 'Server not found'
|
||||
: res.status === 401
|
||||
? 'Invalid api key'
|
||||
: 'Could not connect';
|
||||
|
||||
stale = false; // TODO add notification
|
||||
} else {
|
||||
stale = getIsStale();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
return user.updateUser((prev) => ({
|
||||
...prev,
|
||||
settings: {
|
||||
...prev.settings,
|
||||
radarr: {
|
||||
...prev.settings.radarr,
|
||||
baseUrl,
|
||||
apiKey
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
$: empty = !baseUrl && !apiKey;
|
||||
$: unchanged = baseUrl === $originalBaseUrl && apiKey === $originalApiKey;
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 mb-4">
|
||||
<TextField bind:value={baseUrl} isValid={healthCheck} on:change={handleChange}>
|
||||
Base Url
|
||||
</TextField>
|
||||
<TextField bind:value={apiKey} isValid={healthCheck} on:change={handleChange}>API Key</TextField>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="text-red-500 mb-4">{error}</div>
|
||||
{/if}
|
||||
|
||||
<slot {handleSave} {stale} {empty} {unchanged} />
|
||||
@@ -1,97 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TextField from '../TextField.svelte';
|
||||
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { user } from '../../stores/user.store';
|
||||
import { derived, get } from 'svelte/store';
|
||||
|
||||
let baseUrl = get(user)?.settings.sonarr.baseUrl || '';
|
||||
let apiKey = get(user)?.settings.sonarr.apiKey || '';
|
||||
const originalBaseUrl = derived(user, (u) => u?.settings.sonarr.baseUrl || '');
|
||||
const originalApiKey = derived(user, (u) => u?.settings.sonarr.apiKey || '');
|
||||
|
||||
let stale = false;
|
||||
let error = '';
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let healthCheck: Promise<boolean> | undefined;
|
||||
|
||||
$: {
|
||||
$originalBaseUrl;
|
||||
$originalApiKey;
|
||||
stale = getIsStale();
|
||||
}
|
||||
|
||||
handleChange();
|
||||
|
||||
function getIsStale() {
|
||||
return (
|
||||
(!!healthCheck || (!baseUrl && !apiKey)) &&
|
||||
($originalBaseUrl !== baseUrl || $originalApiKey !== apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
function handleChange() {
|
||||
clearTimeout(timeout);
|
||||
stale = false;
|
||||
error = '';
|
||||
healthCheck = undefined;
|
||||
|
||||
if (baseUrl === '' || apiKey === '') {
|
||||
stale = getIsStale();
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrlCopy = baseUrl;
|
||||
const apiKeyCopy = apiKey;
|
||||
timeout = setTimeout(async () => {
|
||||
const p = sonarrApi.getHealth(baseUrlCopy, apiKeyCopy);
|
||||
healthCheck = p.then((res) => res.status === 200);
|
||||
|
||||
const res = await p;
|
||||
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
|
||||
|
||||
if (res.status !== 200) {
|
||||
error =
|
||||
res.status === 404
|
||||
? 'Server not found'
|
||||
: res.status === 401
|
||||
? 'Invalid api key'
|
||||
: 'Could not connect';
|
||||
|
||||
stale = false; // TODO add notification
|
||||
} else {
|
||||
stale = getIsStale();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
return user.updateUser((prev) => ({
|
||||
...prev,
|
||||
settings: {
|
||||
...prev.settings,
|
||||
sonarr: {
|
||||
...prev.settings.sonarr,
|
||||
baseUrl,
|
||||
apiKey
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
$: empty = !baseUrl && !apiKey;
|
||||
$: unchanged = baseUrl === $originalBaseUrl && apiKey === $originalApiKey;
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 mb-4">
|
||||
<TextField bind:value={baseUrl} isValid={healthCheck} on:change={handleChange}>
|
||||
Base Url
|
||||
</TextField>
|
||||
<TextField bind:value={apiKey} isValid={healthCheck} on:change={handleChange}>API Key</TextField>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="text-red-500 mb-4">{error}</div>
|
||||
{/if}
|
||||
|
||||
<slot {handleSave} {stale} {empty} {unchanged} />
|
||||
@@ -1,11 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ConfirmDialog from '../../Dialog/ConfirmDialog.svelte';
|
||||
|
||||
export let modalId: symbol;
|
||||
export let deleteFile: () => Promise<any>;
|
||||
</script>
|
||||
|
||||
<ConfirmDialog {modalId} confirm={deleteFile}>
|
||||
<h1 slot="header">Delete file?</h1>
|
||||
<div>Are you sure you want to permanently delete this file?</div>
|
||||
</ConfirmDialog>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TableButton from '../../Table/TableButton.svelte';
|
||||
import { Cross1 } from 'radix-icons-svelte';
|
||||
import type { Download } from '../../../apis/combined-types';
|
||||
import type { CancelDownloadFn } from '../MediaManagerModal';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import Container from '../../Container.svelte';
|
||||
import classNames from 'classnames';
|
||||
import { modalStack } from '../../Modal/modal.store';
|
||||
import MMConfirmDeleteFileDialog from '../Dialogs/MMConfirmDeleteFileDialog.svelte';
|
||||
|
||||
export let download: Download;
|
||||
export let cancelDownload: CancelDownloadFn;
|
||||
|
||||
let title = '';
|
||||
let subtitle = '';
|
||||
|
||||
function handleCancelDownload() {
|
||||
modalStack.create(MMConfirmDeleteFileDialog, {
|
||||
deleteFile: () => cancelDownload(download.id || -1)
|
||||
});
|
||||
}
|
||||
|
||||
$: {
|
||||
if ('series' in download && 'episode' in download) {
|
||||
title = download.episode?.title || '';
|
||||
subtitle = `Episode ${download.episode?.episodeNumber || ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
$: console.log('download', download);
|
||||
</script>
|
||||
|
||||
<Container class="contents" let:hasFocusWithin>
|
||||
<div
|
||||
class={classNames(
|
||||
'flex items-center justify-between relative overflow-hidden',
|
||||
'px-6 py-3 bg-secondary-800 border-2 border-secondary-800 rounded-xl',
|
||||
{
|
||||
'bg-secondary-900 border-secondary-500': hasFocusWithin
|
||||
}
|
||||
)}
|
||||
>
|
||||
<!-- Background -->
|
||||
<div
|
||||
class="absolute inset-y-0 bg-secondary-50/10 left-0"
|
||||
style={`width: ${
|
||||
(((download.size || download.sizeleft || 0) - (download.sizeleft || 0)) /
|
||||
(download.size || 1)) *
|
||||
100
|
||||
}%`}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 class="text-zinc-300 font-medium">{subtitle}</h2>
|
||||
<h1 class="text-lg font-medium tracking-wide">{title}</h1>
|
||||
</div>
|
||||
<div>
|
||||
<TableButton
|
||||
on:clickOrSelect={handleCancelDownload}
|
||||
on:enter={scrollIntoView({ vertical: 128 })}
|
||||
>
|
||||
<Cross1 size={19} />
|
||||
</TableButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TableRow from '../../Table/TableRow.svelte';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import type { FileResource } from '../../../apis/combined-types';
|
||||
import { formatSize } from '../../../utils';
|
||||
import TableButton from '../../Table/TableButton.svelte';
|
||||
import { Trash } from 'radix-icons-svelte';
|
||||
import TableCell from '../../Table/TableCell.svelte';
|
||||
import type { DeleteFileFn } from '../MediaManagerModal';
|
||||
import { modalStack } from '../../Modal/modal.store';
|
||||
import MMConfirmDeleteFileDialog from '../Dialogs/MMConfirmDeleteFileDialog.svelte';
|
||||
|
||||
export let file: FileResource;
|
||||
export let deleteFile: DeleteFileFn;
|
||||
</script>
|
||||
|
||||
<TableRow class="font-medium">
|
||||
<TableCell>
|
||||
<div class="font-medium text-lg">
|
||||
{file.sceneName}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-zinc-300">{file.mediaInfo?.runTime}</TableCell>
|
||||
<TableCell class="text-zinc-300">{formatSize(file.size || 0)}</TableCell>
|
||||
<TableCell class="text-zinc-300">{file.quality?.quality?.name}</TableCell>
|
||||
<TableCell>
|
||||
<TableButton
|
||||
on:enter={scrollIntoView({ vertical: 128 })}
|
||||
on:clickOrSelect={() =>
|
||||
modalStack.create(MMConfirmDeleteFileDialog, {
|
||||
deleteFile: () => deleteFile(file.id || -1)
|
||||
})}
|
||||
>
|
||||
<Trash size={19} />
|
||||
</TableButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1,138 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ButtonGhost from '../../Ghosts/ButtonGhost.svelte';
|
||||
import type { Download, FileResource } from '../../../apis/combined-types';
|
||||
import MMLocalFileRow from './MMLocalFileRow.svelte';
|
||||
import TableHeaderRow from '../../Table/TableHeaderRow.svelte';
|
||||
import TableHeaderSortBy from '../../Table/TableHeaderSortBy.svelte';
|
||||
import TableHeaderCell from '../../Table/TableHeaderCell.svelte';
|
||||
import Container from '../../Container.svelte';
|
||||
import Button from '../../Button/Button.svelte';
|
||||
import { Cross1, Trash } from 'radix-icons-svelte';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import type {
|
||||
CancelDownloadFn,
|
||||
CancelDownloadsFn,
|
||||
DeleteFileFn,
|
||||
DeleteFilesFn
|
||||
} from '../MediaManagerModal';
|
||||
import { modalStack } from '../../Modal/modal.store';
|
||||
import MMConfirmDeleteFileDialog from '../Dialogs/MMConfirmDeleteFileDialog.svelte';
|
||||
import MMDownloadRow from '../Downloads/MMDownloadRow.svelte';
|
||||
|
||||
export let files: Promise<FileResource[]>;
|
||||
export let deleteFile: DeleteFileFn;
|
||||
export let deleteFiles: DeleteFilesFn;
|
||||
|
||||
export let downloads: Promise<Download[]>;
|
||||
export let cancelDownload: CancelDownloadFn;
|
||||
export let cancelDownloads: CancelDownloadsFn;
|
||||
|
||||
let sortBy: 'size' | 'quality' | 'title' | 'runtime' | undefined = 'title';
|
||||
let sortDirection: 'asc' | 'desc' = 'desc';
|
||||
|
||||
const toggleSortBy = (sort: typeof sortBy) => () => {
|
||||
if (sortBy === sort) {
|
||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy = sort;
|
||||
sortDirection = 'desc';
|
||||
}
|
||||
};
|
||||
|
||||
function confirmCancelAllDownloads(downloads: Download[]) {
|
||||
modalStack.create(MMConfirmDeleteFileDialog, {
|
||||
deleteFile: () => cancelDownloads(downloads.map((f) => f.id || -1))
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteAllFiles(files: FileResource[]) {
|
||||
modalStack.create(MMConfirmDeleteFileDialog, {
|
||||
deleteFile: () => deleteFiles(files.map((f) => f.id || -1))
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await downloads then downloads}
|
||||
{#if downloads?.length}
|
||||
<h1 class="text-lg font-semibold tracking-wide text-zinc-300 mb-4 mx-12">Downloads</h1>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mx-12 mb-4">
|
||||
{#each downloads as download}
|
||||
<MMDownloadRow {download} {cancelDownload} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Container
|
||||
direction="horizontal"
|
||||
class="flex mb-16 mx-12"
|
||||
on:enter={scrollIntoView({ vertical: 128 })}
|
||||
>
|
||||
<Button on:clickOrSelect={() => confirmCancelAllDownloads(downloads)}>
|
||||
Cancel All Downloads
|
||||
<Cross1 size={19} slot="icon" />
|
||||
</Button>
|
||||
</Container>
|
||||
|
||||
<h1 class="text-2xl font-semibold mb-4 mt-8 mx-12">Local Files</h1>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
{#await files}
|
||||
{#each new Array(5) as _, index}
|
||||
<div class="flex-1 my-2">
|
||||
<ButtonGhost />
|
||||
</div>
|
||||
{/each}
|
||||
{:then files}
|
||||
<div class="grid grid-cols-[1fr_max-content_max-content_max-content_max-content] gap-y-4">
|
||||
<TableHeaderRow>
|
||||
<TableHeaderSortBy
|
||||
icon={sortBy === 'title' ? sortDirection : undefined}
|
||||
on:clickOrSelect={toggleSortBy('title')}
|
||||
>
|
||||
Title
|
||||
</TableHeaderSortBy>
|
||||
<TableHeaderSortBy
|
||||
icon={sortBy === 'runtime' ? sortDirection : undefined}
|
||||
on:clickOrSelect={toggleSortBy('runtime')}
|
||||
>
|
||||
Runtime
|
||||
</TableHeaderSortBy>
|
||||
<TableHeaderSortBy
|
||||
icon={sortBy === 'size' ? sortDirection : undefined}
|
||||
on:clickOrSelect={toggleSortBy('size')}
|
||||
>
|
||||
Size
|
||||
</TableHeaderSortBy>
|
||||
<TableHeaderSortBy
|
||||
icon={sortBy === 'quality' ? sortDirection : undefined}
|
||||
on:clickOrSelect={toggleSortBy('quality')}
|
||||
>
|
||||
Quality
|
||||
</TableHeaderSortBy>
|
||||
<TableHeaderCell />
|
||||
</TableHeaderRow>
|
||||
<Container class="contents" focusedChild>
|
||||
{#each files as file}
|
||||
<MMLocalFileRow {file} {deleteFile} />
|
||||
{/each}
|
||||
</Container>
|
||||
</div>
|
||||
{#if files?.length}
|
||||
<Container
|
||||
direction="horizontal"
|
||||
class="flex mt-8 mx-12"
|
||||
on:enter={scrollIntoView({ vertical: 128 })}
|
||||
>
|
||||
<Button on:clickOrSelect={() => confirmDeleteAllFiles(files)}>
|
||||
Delete all
|
||||
<Trash size={19} slot="icon" />
|
||||
</Button>
|
||||
</Container>
|
||||
{:else}
|
||||
<div class="text-zinc-400 font-medium mx-12 flex flex-col items-center justify-center h-full">
|
||||
<h1 class="text-xl text-zinc-300">No local files found</h1>
|
||||
<div>Your local files will appear here.</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
@@ -1,280 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import { TMDB_BACKDROP_SMALL } from '../../constants';
|
||||
import { type BackEvent, type Selectable } from '../../selectable';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
||||
import {
|
||||
movieAvailabilities,
|
||||
type MovieAvailability,
|
||||
radarrApi
|
||||
} from '../../apis/radarr/radarr-api';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import classNames from 'classnames';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Container from '../Container.svelte';
|
||||
import { capitalize, formatSize } from '../../utils';
|
||||
import { ArrowRight, Check, Plus } from 'radix-icons-svelte';
|
||||
import Button from '../Button/Button.svelte';
|
||||
|
||||
type AddOptionsStore = {
|
||||
rootFolderPath: string | null;
|
||||
qualityProfileId: number | null;
|
||||
minimumAvailability: MovieAvailability | null;
|
||||
};
|
||||
|
||||
export let backdropUri: string;
|
||||
export let tmdbId: number;
|
||||
export let title: string;
|
||||
export let onComplete: () => void = () => {};
|
||||
|
||||
export let modalId: symbol;
|
||||
$: backgroundUrl = TMDB_BACKDROP_SMALL + backdropUri;
|
||||
|
||||
let tab: 'add-to-radarr' | 'root-folders' | 'quality-profiles' | 'monitor-settings' =
|
||||
'add-to-radarr';
|
||||
let addToSonarrTab: Selectable;
|
||||
let rootFoldersTab: Selectable;
|
||||
let qualityProfilesTab: Selectable;
|
||||
let monitorSettingsTab: Selectable;
|
||||
$: {
|
||||
if (tab === 'add-to-radarr' && addToSonarrTab) addToSonarrTab.focus();
|
||||
if (tab === 'root-folders' && rootFoldersTab) rootFoldersTab.focus();
|
||||
if (tab === 'quality-profiles' && qualityProfilesTab) qualityProfilesTab.focus();
|
||||
if (tab === 'monitor-settings' && monitorSettingsTab) monitorSettingsTab.focus();
|
||||
}
|
||||
const addOptionsStore = createLocalStorageStore<AddOptionsStore>('add-to-radarr-options', {
|
||||
rootFolderPath: null,
|
||||
qualityProfileId: null,
|
||||
minimumAvailability: null
|
||||
});
|
||||
|
||||
const sonarrOptions = Promise.all([
|
||||
radarrApi.getRootFolders(),
|
||||
radarrApi.getQualityProfiles()
|
||||
]).then(([rootFolders, qualityProfiles]) => ({ rootFolders, qualityProfiles }));
|
||||
|
||||
sonarrOptions.then((s) => {
|
||||
addOptionsStore.update((prev) => ({
|
||||
rootFolderPath: prev.rootFolderPath || s.rootFolders[0]?.path || null,
|
||||
qualityProfileId: prev.qualityProfileId || s.qualityProfiles[0]?.id || null,
|
||||
minimumAvailability: prev.minimumAvailability || 'released'
|
||||
}));
|
||||
});
|
||||
addOptionsStore.subscribe(() => (tab = 'add-to-radarr'));
|
||||
|
||||
function handleAddToSonarr() {
|
||||
return radarrApi
|
||||
.addMovieToRadarr(tmdbId, {
|
||||
rootFolderPath: $addOptionsStore.rootFolderPath || undefined,
|
||||
qualityProfileId: $addOptionsStore.qualityProfileId || undefined,
|
||||
minimumAvailability: $addOptionsStore.minimumAvailability || undefined
|
||||
})
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
modalStack.close(modalId);
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleBack(e: BackEvent) {
|
||||
if (tab !== 'add-to-radarr') {
|
||||
tab = 'add-to-radarr';
|
||||
e.detail.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
const tabClasses = (active: boolean, secondary: boolean = false) =>
|
||||
classNames('flex flex-col transition-all', {
|
||||
'opacity-0 pointer-events-none': !active,
|
||||
'-translate-x-10': !active && !secondary,
|
||||
'translate-x-10': !active && secondary,
|
||||
'absolute inset-0': secondary
|
||||
});
|
||||
|
||||
const listItemClass = `flex items-center justify-between bg-primary-900 rounded-xl px-6 py-2.5 mb-4 font-medium
|
||||
border-2 border-transparent focus:border-primary-500 hover:border-primary-500 cursor-pointer group`;
|
||||
|
||||
const scaledArrowClas = (hasFocus: boolean) =>
|
||||
classNames('transition-transform', {
|
||||
'text-primary-500 translate-x-0.5 scale-110': hasFocus,
|
||||
'group-hover:text-primary-500 group-hover:translate-x-0.5 group-hover:scale-110': true
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog>
|
||||
{#if backgroundUrl && tab === 'add-to-radarr'}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="absolute inset-0 bg-cover bg-center h-52"
|
||||
style="background-image: url({backgroundUrl}); -webkit-mask-image: radial-gradient(at 90% 10%, hsla(0,0%,0%,1) 0px, transparent 70%);"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#await sonarrOptions then { qualityProfiles, rootFolders }}
|
||||
{@const selectedRootFolder = rootFolders.find(
|
||||
(f) => f.path === $addOptionsStore.rootFolderPath
|
||||
)}
|
||||
{@const selectedQualityProfile = qualityProfiles.find(
|
||||
(f) => f.id === $addOptionsStore.qualityProfileId
|
||||
)}
|
||||
<Container on:back={handleBack} class="relative">
|
||||
<Container
|
||||
trapFocus
|
||||
bind:selectable={addToSonarrTab}
|
||||
class={tabClasses(tab === 'add-to-radarr')}
|
||||
>
|
||||
<div class="z-10 mb-8">
|
||||
<div class="h-24" />
|
||||
<h1 class="h3">Add {title} to Sonarr?</h1>
|
||||
<div class="font-medium text-secondary-300 mb-8">
|
||||
Before you can fetch episodes, you need to add this series to Sonarr.
|
||||
</div>
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:clickOrSelect={() => (tab = 'root-folders')}
|
||||
let:hasFocus
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">Root Folder</h1>
|
||||
{selectedRootFolder?.path}
|
||||
({formatSize(selectedRootFolder?.freeSpace || 0)} left)
|
||||
</div>
|
||||
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:clickOrSelect={() => (tab = 'quality-profiles')}
|
||||
let:hasFocus
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">
|
||||
Quality Profile
|
||||
</h1>
|
||||
<span>
|
||||
{selectedQualityProfile?.name}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:clickOrSelect={() => (tab = 'monitor-settings')}
|
||||
let:hasFocus
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">
|
||||
Minimum Availability
|
||||
</h1>
|
||||
<span>
|
||||
{capitalize($addOptionsStore.minimumAvailability || 'released')}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
|
||||
</Container>
|
||||
|
||||
<!-- <Container class="flex items-center" on:clickOrSelect={() => (tab = 'quality-profiles')}>-->
|
||||
<!-- {qualityProfile?.name}-->
|
||||
<!-- <ArrowRight size={19} />-->
|
||||
<!-- </Container>-->
|
||||
<!-- <Container class="flex items-center" on:clickOrSelect={() => (tab = 'monitor-settings')}>-->
|
||||
<!-- Monitor {$addOptionsStore.monitorSettings}-->
|
||||
<!-- <ArrowRight size={19} />-->
|
||||
<!-- </Container>-->
|
||||
</div>
|
||||
<Container class="flex flex-col space-y-4">
|
||||
<Button type="primary-dark" action={handleAddToSonarr} focusOnMount>
|
||||
<Plus size={19} slot="icon" />
|
||||
Add to Radarr
|
||||
</Button>
|
||||
<Button type="primary-dark" on:clickOrSelect={() => modalStack.close(modalId)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
trapFocus
|
||||
class={tabClasses(tab === 'root-folders', true)}
|
||||
bind:selectable={rootFoldersTab}
|
||||
>
|
||||
<h1 class="text-xl text-secondary-100 font-medium mb-4">Root Folder</h1>
|
||||
<div class="min-h-0 overflow-y-auto scrollbar-hide">
|
||||
{#each rootFolders as rootFolder}
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:enter={scrollIntoView({ vertical: 64 })}
|
||||
on:clickOrSelect={() =>
|
||||
addOptionsStore.update((prev) => ({ ...prev, rootFolderId: rootFolder.id || 0 }))}
|
||||
focusOnClick
|
||||
focusOnMount={$addOptionsStore.rootFolderPath === rootFolder.path}
|
||||
>
|
||||
<div>
|
||||
{rootFolder.path} ({formatSize(rootFolder.freeSpace || 0)} left)
|
||||
</div>
|
||||
{#if selectedRootFolder?.id === rootFolder.id}
|
||||
<Check size={24} />
|
||||
{/if}
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
trapFocus
|
||||
class={tabClasses(tab === 'quality-profiles', true)}
|
||||
bind:selectable={qualityProfilesTab}
|
||||
>
|
||||
<h1 class="text-xl text-secondary-100 font-medium mb-4">Quality Profile</h1>
|
||||
<div class="min-h-0 overflow-y-auto scrollbar-hide">
|
||||
{#each qualityProfiles as qualityProfile}
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:enter={scrollIntoView({ vertical: 64 })}
|
||||
on:clickOrSelect={() =>
|
||||
addOptionsStore.update((prev) => ({
|
||||
...prev,
|
||||
qualityProfileId: qualityProfile.id || 0
|
||||
}))}
|
||||
focusOnClick
|
||||
focusOnMount={$addOptionsStore.qualityProfileId === qualityProfile.id}
|
||||
>
|
||||
<div>{qualityProfile.name}</div>
|
||||
{#if selectedQualityProfile?.id === qualityProfile.id}
|
||||
<Check size={24} />
|
||||
{/if}
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
trapFocus
|
||||
class={tabClasses(tab === 'monitor-settings', true)}
|
||||
bind:selectable={monitorSettingsTab}
|
||||
>
|
||||
<h1 class="text-xl text-secondary-100 font-medium mb-4">Monitor Episodes</h1>
|
||||
<div class="min-h-0 overflow-y-auto scrollbar-hide">
|
||||
{#each movieAvailabilities as availibility}
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:enter={scrollIntoView({ vertical: 64 })}
|
||||
on:clickOrSelect={() =>
|
||||
addOptionsStore.update((prev) => ({ ...prev, monitorOptions: availibility }))}
|
||||
focusOnClick
|
||||
focusOnMount={$addOptionsStore.minimumAvailability === availibility}
|
||||
>
|
||||
<div>{capitalize(availibility)}</div>
|
||||
{#if $addOptionsStore.minimumAvailability === availibility}
|
||||
<Check size={24} />
|
||||
{/if}
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
{/await}
|
||||
</Dialog>
|
||||
@@ -1,281 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import Container from '../Container.svelte';
|
||||
import Button from '../Button/Button.svelte';
|
||||
import { ArrowRight, Check, Plus, Trash } from 'radix-icons-svelte';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import {
|
||||
sonarrApi,
|
||||
type SonarrMonitorOptions,
|
||||
sonarrMonitorOptions
|
||||
} from '../../apis/sonarr/sonarr-api';
|
||||
import { TMDB_BACKDROP_SMALL } from '../../constants';
|
||||
import classNames from 'classnames';
|
||||
import { type BackEvent, Selectable } from '../../selectable';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
||||
import { formatSize } from '../../utils';
|
||||
import { capitalize } from '../../utils.js';
|
||||
|
||||
type AddOptionsStore = {
|
||||
rootFolderPath: string | null;
|
||||
qualityProfileId: number | null;
|
||||
monitorOptions: SonarrMonitorOptions | null;
|
||||
};
|
||||
|
||||
export let backdropUri: string;
|
||||
export let tmdbId: number;
|
||||
export let title: string;
|
||||
|
||||
export let modalId: symbol;
|
||||
export let onComplete: (() => void) | (() => Promise<any>) = () => Promise.resolve();
|
||||
$: backgroundUrl = TMDB_BACKDROP_SMALL + backdropUri;
|
||||
|
||||
let tab: 'add-to-sonarr' | 'root-folders' | 'quality-profiles' | 'monitor-settings' =
|
||||
'add-to-sonarr';
|
||||
let addToSonarrTab: Selectable;
|
||||
let rootFoldersTab: Selectable;
|
||||
let qualityProfilesTab: Selectable;
|
||||
let monitorSettingsTab: Selectable;
|
||||
$: {
|
||||
if (tab === 'add-to-sonarr' && addToSonarrTab) addToSonarrTab.focus();
|
||||
if (tab === 'root-folders' && rootFoldersTab) rootFoldersTab.focus();
|
||||
if (tab === 'quality-profiles' && qualityProfilesTab) qualityProfilesTab.focus();
|
||||
if (tab === 'monitor-settings' && monitorSettingsTab) monitorSettingsTab.focus();
|
||||
}
|
||||
const addOptionsStore = createLocalStorageStore<AddOptionsStore>('add-to-sonarr-options', {
|
||||
rootFolderPath: null,
|
||||
qualityProfileId: null,
|
||||
monitorOptions: null
|
||||
});
|
||||
|
||||
const sonarrOptions = Promise.all([
|
||||
sonarrApi.getRootFolders(),
|
||||
sonarrApi.getQualityProfiles()
|
||||
]).then(([rootFolders, qualityProfiles]) => ({ rootFolders, qualityProfiles }));
|
||||
|
||||
sonarrOptions.then((s) => {
|
||||
addOptionsStore.update((prev) => ({
|
||||
rootFolderPath: prev.rootFolderPath || s.rootFolders[0]?.path || null,
|
||||
qualityProfileId: prev.qualityProfileId || s.qualityProfiles[0]?.id || null,
|
||||
monitorOptions: prev.monitorOptions || 'none'
|
||||
}));
|
||||
});
|
||||
addOptionsStore.subscribe(() => (tab = 'add-to-sonarr'));
|
||||
|
||||
async function handleAddToSonarr() {
|
||||
return sonarrApi
|
||||
.addToSonarr(tmdbId as number, {
|
||||
rootFolderPath: $addOptionsStore.rootFolderPath || undefined,
|
||||
monitorOptions: $addOptionsStore.monitorOptions || undefined,
|
||||
qualityProfileId: $addOptionsStore.qualityProfileId || undefined
|
||||
})
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
modalStack.close(modalId);
|
||||
return onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleBack(e: BackEvent) {
|
||||
if (tab !== 'add-to-sonarr') {
|
||||
tab = 'add-to-sonarr';
|
||||
e.detail.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
const tabClasses = (active: boolean, secondary: boolean = false) =>
|
||||
classNames('flex flex-col transition-all', {
|
||||
'opacity-0 pointer-events-none': !active,
|
||||
'-translate-x-10': !active && !secondary,
|
||||
'translate-x-10': !active && secondary,
|
||||
'absolute inset-0': secondary
|
||||
});
|
||||
|
||||
const listItemClass = `flex items-center justify-between bg-primary-900 rounded-xl px-6 py-2.5 mb-4 font-medium
|
||||
border-2 border-transparent focus:border-primary-500 hover:border-primary-500 cursor-pointer group`;
|
||||
|
||||
const scaledArrowClas = (hasFocus: boolean) =>
|
||||
classNames('transition-transform', {
|
||||
'text-primary-500 translate-x-0.5 scale-110': hasFocus,
|
||||
'group-hover:text-primary-500 group-hover:translate-x-0.5 group-hover:scale-110': true
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog>
|
||||
{#if backgroundUrl && tab === 'add-to-sonarr'}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="absolute inset-0 bg-cover bg-center h-52"
|
||||
style="background-image: url({backgroundUrl}); -webkit-mask-image: radial-gradient(at 90% 10%, hsla(0,0%,0%,1) 0px, transparent 70%);"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#await sonarrOptions then { qualityProfiles, rootFolders }}
|
||||
{@const selectedRootFolder = rootFolders.find(
|
||||
(f) => f.path === $addOptionsStore.rootFolderPath
|
||||
)}
|
||||
{@const selectedQualityProfile = qualityProfiles.find(
|
||||
(f) => f.id === $addOptionsStore.qualityProfileId
|
||||
)}
|
||||
<Container on:back={handleBack} class="relative">
|
||||
<Container
|
||||
trapFocus
|
||||
bind:selectable={addToSonarrTab}
|
||||
class={tabClasses(tab === 'add-to-sonarr')}
|
||||
>
|
||||
<div class="z-10 mb-8">
|
||||
<div class="h-24" />
|
||||
<h1 class="h3">Add {title} to Sonarr?</h1>
|
||||
<div class="font-medium text-secondary-300 mb-8">
|
||||
Before you can fetch episodes, you need to add this series to Sonarr.
|
||||
</div>
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:clickOrSelect={() => (tab = 'root-folders')}
|
||||
let:hasFocus
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">Root Folder</h1>
|
||||
{selectedRootFolder?.path}
|
||||
({formatSize(selectedRootFolder?.freeSpace || 0)} left)
|
||||
</div>
|
||||
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:clickOrSelect={() => (tab = 'quality-profiles')}
|
||||
let:hasFocus
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">
|
||||
Quality Profile
|
||||
</h1>
|
||||
<span>
|
||||
{selectedQualityProfile?.name}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:clickOrSelect={() => (tab = 'monitor-settings')}
|
||||
let:hasFocus
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">
|
||||
Monitor Strategy
|
||||
</h1>
|
||||
<span>
|
||||
{capitalize($addOptionsStore.monitorOptions || 'none')}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
|
||||
</Container>
|
||||
|
||||
<!-- <Container class="flex items-center" on:clickOrSelect={() => (tab = 'quality-profiles')}>-->
|
||||
<!-- {qualityProfile?.name}-->
|
||||
<!-- <ArrowRight size={19} />-->
|
||||
<!-- </Container>-->
|
||||
<!-- <Container class="flex items-center" on:clickOrSelect={() => (tab = 'monitor-settings')}>-->
|
||||
<!-- Monitor {$addOptionsStore.monitorSettings}-->
|
||||
<!-- <ArrowRight size={19} />-->
|
||||
<!-- </Container>-->
|
||||
</div>
|
||||
<Container class="flex flex-col space-y-4">
|
||||
<Button type="primary-dark" action={handleAddToSonarr} focusOnMount>
|
||||
<Plus size={19} slot="icon" />
|
||||
Add to Sonarr
|
||||
</Button>
|
||||
<Button type="primary-dark" on:clickOrSelect={() => modalStack.close(modalId)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
trapFocus
|
||||
class={tabClasses(tab === 'root-folders', true)}
|
||||
bind:selectable={rootFoldersTab}
|
||||
>
|
||||
<h1 class="text-xl text-secondary-100 font-medium mb-4">Root Folder</h1>
|
||||
<div class="min-h-0 overflow-y-auto scrollbar-hide">
|
||||
{#each rootFolders as rootFolder}
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:enter={scrollIntoView({ vertical: 64 })}
|
||||
on:clickOrSelect={() =>
|
||||
addOptionsStore.update((prev) => ({ ...prev, rootFolderId: rootFolder.id || 0 }))}
|
||||
focusOnClick
|
||||
focusOnMount={$addOptionsStore.rootFolderPath === rootFolder.path}
|
||||
>
|
||||
<div>
|
||||
{rootFolder.path} ({formatSize(rootFolder.freeSpace || 0)} left)
|
||||
</div>
|
||||
{#if selectedRootFolder?.id === rootFolder.id}
|
||||
<Check size={24} />
|
||||
{/if}
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
trapFocus
|
||||
class={tabClasses(tab === 'quality-profiles', true)}
|
||||
bind:selectable={qualityProfilesTab}
|
||||
>
|
||||
<h1 class="text-xl text-secondary-100 font-medium mb-4">Quality Profile</h1>
|
||||
<div class="min-h-0 overflow-y-auto scrollbar-hide">
|
||||
{#each qualityProfiles as qualityProfile}
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:enter={scrollIntoView({ vertical: 64 })}
|
||||
on:clickOrSelect={() =>
|
||||
addOptionsStore.update((prev) => ({
|
||||
...prev,
|
||||
qualityProfileId: qualityProfile.id || 0
|
||||
}))}
|
||||
focusOnClick
|
||||
focusOnMount={$addOptionsStore.qualityProfileId === qualityProfile.id}
|
||||
>
|
||||
<div>{qualityProfile.name}</div>
|
||||
{#if selectedQualityProfile?.id === qualityProfile.id}
|
||||
<Check size={24} />
|
||||
{/if}
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
trapFocus
|
||||
class={tabClasses(tab === 'monitor-settings', true)}
|
||||
bind:selectable={monitorSettingsTab}
|
||||
>
|
||||
<h1 class="text-xl text-secondary-100 font-medium mb-4">Monitor Episodes</h1>
|
||||
<div class="min-h-0 overflow-y-auto scrollbar-hide">
|
||||
{#each sonarrMonitorOptions as monitorOption}
|
||||
<Container
|
||||
class={listItemClass}
|
||||
on:enter={scrollIntoView({ vertical: 64 })}
|
||||
on:clickOrSelect={() =>
|
||||
addOptionsStore.update((prev) => ({ ...prev, monitorOptions: monitorOption }))}
|
||||
focusOnClick
|
||||
focusOnMount={$addOptionsStore.monitorOptions === monitorOption}
|
||||
>
|
||||
<div>{capitalize(monitorOption)}</div>
|
||||
{#if $addOptionsStore.monitorOptions === monitorOption}
|
||||
<Check size={24} />
|
||||
{/if}
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
{/await}
|
||||
</Dialog>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Container from '../Container.svelte';
|
||||
import classNames from 'classnames';
|
||||
import MMTitle from './MMTitle.svelte';
|
||||
|
||||
// let activeTab: 'releases' | 'local-files' = 'releases';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-screen">
|
||||
<div class="flex items-center pb-8 mb-8 pt-16 px-32">
|
||||
<div class="flex-1">
|
||||
<MMTitle>
|
||||
<slot name="title" slot="title" />
|
||||
<slot name="subtitle" slot="subtitle" />
|
||||
</MMTitle>
|
||||
</div>
|
||||
<div class="mx-20">
|
||||
<!-- <h1 class="mb-2">Downloads</h1>-->
|
||||
<slot name="downloads" />
|
||||
</div>
|
||||
</div>
|
||||
<Container focusOnMount direction="horizontal" class="flex-1 grid grid-cols-1 min-h-0">
|
||||
<slot />
|
||||
</Container>
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import Container from '../Container.svelte';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean = false;
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<div
|
||||
class={classNames(
|
||||
'fixed inset-0 overflow-hidden',
|
||||
{
|
||||
'opacity-0': hidden
|
||||
},
|
||||
// 'bg-[radial-gradient(169.40%_89.55%_at_94.76%_6.29%,rgba(0,0,0,0.40)_0%,rgba(255,255,255,0.00)_100%)]'
|
||||
// 'bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2BF0_0%,_#073AFF00_100%),linear-gradient(0deg,_#1A1914FF_0%,_#1A1914FF_0%)]'
|
||||
'bg-secondary-900'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 h-screen -z-10 bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2Bcc_0%,_#00000000_100%)]"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -1 +0,0 @@
|
||||
<div>select season</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
<div {...$$restProps}>
|
||||
<div class="h1">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<div class="h4">
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { Release } from '../../apis/combined-types';
|
||||
|
||||
export type GrabReleaseFn = (release: Release) => Promise<boolean>;
|
||||
export type DeleteFileFn = (id: number) => Promise<boolean>;
|
||||
export type DeleteFilesFn = (ids: number[]) => Promise<boolean>;
|
||||
export type CancelDownloadFn = (downloadId: number) => Promise<boolean>;
|
||||
export type CancelDownloadsFn = (downloadIds: number[]) => Promise<boolean>;
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
// import MMAddToSonarr from './MMAddToSonarr.svelte';
|
||||
import { radarrApi, type RadarrMovie } from '../../apis/radarr/radarr-api';
|
||||
import type { GrabReleaseFn } from './MediaManagerModal';
|
||||
import type { Release } from '../../apis/combined-types';
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import MMReleasesTab from './Releases/MMReleasesTab.svelte';
|
||||
import { retry } from '../../utils';
|
||||
|
||||
export let radarrItem: RadarrMovie;
|
||||
export let onGrabRelease: (release: Release) => void = () => {};
|
||||
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean;
|
||||
|
||||
$: releases = retry(
|
||||
() => radarrApi.getReleases(radarrItem.id || -1),
|
||||
(v) => !!v?.length,
|
||||
{ retries: 2 }
|
||||
);
|
||||
|
||||
const grabRelease: GrabReleaseFn = (release) =>
|
||||
radarrApi.downloadMovie(release.guid || '', release.indexerId || -1).then((r) => {
|
||||
onGrabRelease(release);
|
||||
return r;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog size="full" {modalId} {hidden}>
|
||||
<MMReleasesTab {releases} {grabRelease}>
|
||||
<h1 slot="title">{radarrItem?.title}</h1>
|
||||
<h2 slot="subtitle">
|
||||
Releases
|
||||
<!--{#if season}-->
|
||||
<!-- Season {season} Releases-->
|
||||
<!--{:else if 'episodeNumber' in sonarrItem}-->
|
||||
<!-- Episode {sonarrItem.episodeNumber} Releases-->
|
||||
<!--{/if}-->
|
||||
</h2>
|
||||
</MMReleasesTab>
|
||||
</Dialog>
|
||||
|
||||
<!--<MMModal {modalId} {hidden}>-->
|
||||
<!-- {#await radarrItem then movie}-->
|
||||
<!-- {#if !movie}-->
|
||||
<!-- <!– <MMAddToSonarr />–>-->
|
||||
<!-- {:else}-->
|
||||
<!-- <MMMainLayout>-->
|
||||
<!-- <h1 slot="title">{movie?.title}</h1>-->
|
||||
<!-- <ReleaseList slot="releases" {getReleases} {selectRelease} />-->
|
||||
<!-- <DownloadList slot="downloads" {downloads} {cancelDownload} />-->
|
||||
<!-- <FileList slot="local-files" {files} {handleSelectFile} />-->
|
||||
<!-- </MMMainLayout>-->
|
||||
<!-- {/if}-->
|
||||
<!-- {/await}-->
|
||||
<!--</MMModal>-->
|
||||
@@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { formatMinutesToTime, formatSize } from '../../../utils.js';
|
||||
import type { RadarrRelease } from '../../../apis/radarr/radarr-api';
|
||||
import type { SonarrRelease } from '../../../apis/sonarr/sonarr-api';
|
||||
import { scrollIntoView } from '$lib/selectable.js';
|
||||
import { Check, Download } from 'radix-icons-svelte';
|
||||
import TableRow from '../../Table/TableRow.svelte';
|
||||
import type { GrabReleaseFn } from '../MediaManagerModal';
|
||||
import TableButton from '../../Table/TableButton.svelte';
|
||||
import TableCell from '../../Table/TableCell.svelte';
|
||||
export let release: RadarrRelease | SonarrRelease;
|
||||
|
||||
export let grabRelease: GrabReleaseFn;
|
||||
let fetching = false;
|
||||
let didGrab = false;
|
||||
|
||||
function handleGrabRelease() {
|
||||
fetching = true;
|
||||
grabRelease(release).then((ok) => {
|
||||
fetching = false;
|
||||
didGrab = ok;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<TableRow class="font-medium">
|
||||
<TableCell>
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-zinc-300 mb-1">
|
||||
{formatMinutesToTime(release.ageMinutes || 0)} ago
|
||||
</h2>
|
||||
<h1 class="font-medium text-lg">{release.title}</h1>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-zinc-300">
|
||||
{formatSize(release.size || 0)}
|
||||
</TableCell>
|
||||
<TableCell class="text-zinc-300">
|
||||
<div
|
||||
class="px-3 py-1 rounded bg-secondary-700 flex items-center justify-center float-left text-sm"
|
||||
>
|
||||
{release.seeders} / {release.leechers}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-zinc-300">
|
||||
<div
|
||||
class="px-3 py-1 rounded bg-secondary-700 flex items-center justify-center float-left text-sm"
|
||||
>
|
||||
{release.quality?.quality?.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableButton
|
||||
disabled={didGrab || fetching}
|
||||
on:clickOrSelect={handleGrabRelease}
|
||||
on:enter={scrollIntoView({ vertical: 128 })}
|
||||
>
|
||||
<svelte:component this={didGrab ? Check : Download} size={19} />
|
||||
</TableButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type RadarrRelease } from '../../../apis/radarr/radarr-api';
|
||||
import ButtonGhost from '../../Ghosts/ButtonGhost.svelte';
|
||||
import type { SonarrRelease } from '../../../apis/sonarr/sonarr-api';
|
||||
import MMReleaseListRow from './MMReleaseListRow.svelte';
|
||||
import TableHeaderRow from '../../Table/TableHeaderRow.svelte';
|
||||
import TableHeaderSortBy from '../../Table/TableHeaderSortBy.svelte';
|
||||
import type { GrabReleaseFn } from '../MediaManagerModal';
|
||||
import Container from '../../Container.svelte';
|
||||
import TableHeaderCell from '../../Table/TableHeaderCell.svelte';
|
||||
import MMTitle from '../MMTitle.svelte';
|
||||
|
||||
type Release = RadarrRelease | SonarrRelease;
|
||||
|
||||
export let releases: Promise<Release[]>;
|
||||
export let grabRelease: GrabReleaseFn;
|
||||
|
||||
let sortBy: 'size' | 'quality' | 'seeders' | 'age' | undefined = 'seeders';
|
||||
let sortDirection: 'asc' | 'desc' = 'desc';
|
||||
|
||||
function getRecommendedReleases(releases: Release[]) {
|
||||
if (!releases) return [];
|
||||
let filtered = releases.slice();
|
||||
|
||||
const releaseIsEnough = (r: Release) => r?.quality?.quality?.resolution || 0 > 720;
|
||||
filtered.sort((a, b) => (b.seeders || 0) - (a.seeders || 0));
|
||||
filtered.sort((a, b) => (releaseIsEnough(b) ? 1 : 0) - (releaseIsEnough(a) ? 1 : 0));
|
||||
filtered = filtered.slice(0, 5);
|
||||
|
||||
filtered.sort((a, b) => (b.size || 0) - (a.size || 0));
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
const toggleSortBy = (sort: typeof sortBy) => () => {
|
||||
if (sortBy === sort) {
|
||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy = sort;
|
||||
sortDirection = 'desc';
|
||||
}
|
||||
};
|
||||
|
||||
function getSortFn(sb: typeof sortBy, sd: typeof sortDirection) {
|
||||
return (a: Release, b: Release) => {
|
||||
if (sb === 'size') {
|
||||
return (sd === 'asc' ? 1 : -1) * ((a.size || 0) - (b.size || 0));
|
||||
}
|
||||
if (sb === 'quality') {
|
||||
return (
|
||||
(sd === 'asc' ? 1 : -1) *
|
||||
((a.quality?.quality?.resolution || 0) - (b.quality?.quality?.resolution || 0))
|
||||
);
|
||||
}
|
||||
if (sb === 'seeders') {
|
||||
return (sd === 'asc' ? 1 : -1) * ((a.seeders || 0) - (b.seeders || 0));
|
||||
}
|
||||
if (sb === 'age') {
|
||||
return (sd === 'asc' ? 1 : -1) * ((b.ageHours || 0) - (a.ageHours || 0));
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container trapFocus class="py-8 h-full flex flex-col">
|
||||
<h1 class="h1 mx-12">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<h2 class="h4 mx-12 mb-8">
|
||||
<slot name="subtitle" />
|
||||
</h2>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide">
|
||||
{#await releases}
|
||||
{#each new Array(5) as _, index}
|
||||
<div class="flex-1 my-2">
|
||||
<ButtonGhost />
|
||||
</div>
|
||||
{/each}
|
||||
{:then releases}
|
||||
<div class="grid grid-cols-[1fr_max-content_max-content_max-content_max-content] gap-y-4">
|
||||
<TableHeaderRow>
|
||||
<TableHeaderSortBy
|
||||
icon={sortBy === 'age' ? sortDirection : undefined}
|
||||
on:clickOrSelect={toggleSortBy('age')}
|
||||
>
|
||||
Age
|
||||
</TableHeaderSortBy>
|
||||
<TableHeaderSortBy
|
||||
icon={sortBy === 'size' ? sortDirection : undefined}
|
||||
on:clickOrSelect={toggleSortBy('size')}
|
||||
>
|
||||
Size
|
||||
</TableHeaderSortBy>
|
||||
<TableHeaderSortBy
|
||||
icon={sortBy === 'seeders' ? sortDirection : undefined}
|
||||
on:clickOrSelect={toggleSortBy('seeders')}
|
||||
>
|
||||
Peers
|
||||
</TableHeaderSortBy>
|
||||
<TableHeaderSortBy
|
||||
icon={sortBy === 'quality' ? sortDirection : undefined}
|
||||
on:clickOrSelect={toggleSortBy('quality')}
|
||||
>
|
||||
Quality
|
||||
</TableHeaderSortBy>
|
||||
<TableHeaderCell />
|
||||
</TableHeaderRow>
|
||||
|
||||
<Container class="contents" focusOnMount>
|
||||
{#each getRecommendedReleases(releases).sort(getSortFn(sortBy, sortDirection)) as release, index}
|
||||
<MMReleaseListRow {release} {grabRelease} />
|
||||
{/each}
|
||||
</Container>
|
||||
|
||||
<h1 class="text-2xl font-semibold mb-4 mt-8 col-span-5 mx-12">All Releases</h1>
|
||||
|
||||
{#each releases
|
||||
.filter((r) => r.guid && r.indexerId)
|
||||
.sort(getSortFn(sortBy, sortDirection)) as release, index}
|
||||
<MMReleaseListRow {release} {grabRelease} />
|
||||
{/each}
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
</Container>
|
||||
@@ -1,63 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { sonarrApi, type SonarrEpisode, type SonarrSeries } from '../../apis/sonarr/sonarr-api';
|
||||
import MMReleasesTab from './Releases/MMReleasesTab.svelte';
|
||||
import type { GrabReleaseFn } from './MediaManagerModal';
|
||||
import { onDestroy } from 'svelte';
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import type { Release } from '../../apis/combined-types';
|
||||
import MMSeasonSelectTab from './MMSeasonSelectTab.svelte';
|
||||
import { retry } from '../../utils';
|
||||
|
||||
export let season: number | undefined = undefined;
|
||||
export let sonarrItem: SonarrSeries | SonarrEpisode;
|
||||
export let onGrabRelease: (release: Release) => void = () => {};
|
||||
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean;
|
||||
|
||||
$: releases = getReleases(season);
|
||||
|
||||
let refreshDownloadsTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const grabRelease: GrabReleaseFn = (release) =>
|
||||
sonarrApi.downloadSonarrRelease(release.guid || '', release.indexerId || -1).then((r) => {
|
||||
onGrabRelease(release);
|
||||
return r;
|
||||
});
|
||||
|
||||
function getReleases(season?: number) {
|
||||
if (season)
|
||||
return retry(
|
||||
() => sonarrApi.getSeasonReleases(sonarrItem.id || -1, season),
|
||||
(v) => !!v?.length,
|
||||
{ retries: 2 }
|
||||
);
|
||||
else
|
||||
return retry(
|
||||
() => sonarrApi.getEpisodeReleases(sonarrItem.id || -1),
|
||||
(v) => !!v?.length,
|
||||
{ retries: 2 }
|
||||
);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(refreshDownloadsTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog size="full" {modalId} {hidden}>
|
||||
{#if 'seasons' in sonarrItem && !season}
|
||||
<MMSeasonSelectTab />
|
||||
{:else}
|
||||
<MMReleasesTab {releases} {grabRelease}>
|
||||
<h1 slot="title">{sonarrItem?.title}</h1>
|
||||
<h2 slot="subtitle">
|
||||
{#if season}
|
||||
Season {season} Releases
|
||||
{:else if 'episodeNumber' in sonarrItem}
|
||||
Episode {sonarrItem.episodeNumber} Releases
|
||||
{/if}
|
||||
</h2>
|
||||
</MMReleasesTab>
|
||||
{/if}
|
||||
</Dialog>
|
||||
@@ -1,188 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
ListWithDetailsViewDto,
|
||||
MediaSourceDto,
|
||||
ViewBaseDto
|
||||
} from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import { capitalize } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import Container from '../Container.svelte';
|
||||
import { reiverrApi } from '$lib/stores/user.store';
|
||||
import {
|
||||
breadcrumbsContext,
|
||||
playableDataContext
|
||||
} from '$lib/pages/TitlePages/ActionsPage/actions-page';
|
||||
import ActionsMenuContainer from '../../pages/TitlePages/ActionsPage/ActionsMenuContainer.svelte';
|
||||
import { DividerHorizontal, Play, TriangleRight } from 'radix-icons-svelte';
|
||||
import { scrollElementIntoView } from '$lib/scroll-into-view';
|
||||
import Marquee from '$lib/components/Marquee.svelte';
|
||||
import ActionPageTitle from '../../pages/TitlePages/ActionsPage/ActionPageTitle.svelte';
|
||||
|
||||
type Row = ListWithDetailsViewDto['items'][number];
|
||||
|
||||
export let viewBase: ViewBaseDto;
|
||||
export let source: MediaSourceDto;
|
||||
export let name = '';
|
||||
|
||||
const { tmdbId, season, episode, playStream, handleAction, handleOpenView } =
|
||||
playableDataContext.getContext();
|
||||
if (name) breadcrumbsContext.createContext(name);
|
||||
|
||||
if (viewBase.type !== 'list-with-details') {
|
||||
throw new Error('Invalid view type');
|
||||
}
|
||||
|
||||
let view = reiverrApi.sources
|
||||
.getView(source.id, viewBase.id, {
|
||||
tmdbId,
|
||||
season,
|
||||
episode
|
||||
})
|
||||
.then((r) => r.data.view as ListWithDetailsViewDto);
|
||||
|
||||
let selectedRow: Row;
|
||||
let selectedActionIndex = 0;
|
||||
let actionsLoading = false;
|
||||
|
||||
function handleItemAction(row: Row) {
|
||||
const action = row.actions[selectedActionIndex];
|
||||
|
||||
if (action?.type === 'action' && action.action === 'stream') {
|
||||
playStream({ source: source, streamId: row.id });
|
||||
} else if (action?.type === 'action' && !action.disabled) {
|
||||
handleAction(source, row.id, action.action);
|
||||
} else if (action?.type === 'open-view') {
|
||||
handleOpenView(source, action.viewId, row.id);
|
||||
}
|
||||
|
||||
// if (action.action === 'stream') {
|
||||
// playStream?.(source, row.id);
|
||||
// } else {
|
||||
// actionsLoading = true;
|
||||
|
||||
// reiverrApi.sources.handleMediaSourceAction(source.id, row.id, action.action).finally(() => {
|
||||
// actionsLoading = false;
|
||||
// });
|
||||
// }
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionsMenuContainer class="h-screen !px-0 [&>*:first-child]:px-32">
|
||||
{#if selectedRow}
|
||||
<!-- <div>
|
||||
<h1>{selectedRow.label}</h1>
|
||||
<div>
|
||||
{#each selectedRow.actions as action, index}
|
||||
{@const selected = selectedActionIndex === index}
|
||||
<div>A: {action.label}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
{#if selectedRow}
|
||||
<div
|
||||
class="space-y-4 rounded-2xl h-52 flex-shrink-0 flex flex-col justify-between bg-primary-200/10 p-8 mx-24"
|
||||
>
|
||||
<div>
|
||||
{#key selectedRow.label}
|
||||
<Marquee class="text-3xl font-semibold text-primary-100">
|
||||
{capitalize(selectedRow.label)}
|
||||
</Marquee>
|
||||
{/key}
|
||||
|
||||
<span class="text-secondary-200 line-clamp-2">
|
||||
{selectedRow.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
{#each selectedRow.actions as action, index}
|
||||
{@const selected = index === selectedActionIndex}
|
||||
<div
|
||||
class={classNames(
|
||||
'inline-flex items-center font-medium tracking-wide h-12 ',
|
||||
'group rounded-xl px-6 bg-primary-900',
|
||||
'border-2 p-1 hover:border-primary-500',
|
||||
{
|
||||
'text-primary-100 border-transparent': !selected,
|
||||
'text-primary-100 border-primary-400': selected,
|
||||
'cursor-pointer': !(action.type === 'action' && action.disabled)
|
||||
}
|
||||
)}
|
||||
>
|
||||
{#if action.type === 'action' && action.action === 'stream'}
|
||||
<TriangleRight size={28} class="-ml-2 mr-1" />
|
||||
{/if}
|
||||
|
||||
{action.label}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div
|
||||
class="overflow-y-auto overflow-x-hidden scrollbar-hide pb-16 mx-32"
|
||||
style="backface-visibility: hidden"
|
||||
>
|
||||
{#await view}
|
||||
Loading...
|
||||
{:then view}
|
||||
{#each view.items as row, index}
|
||||
<Container
|
||||
on:enter={({ detail }) => {
|
||||
selectedRow = row;
|
||||
selectedActionIndex = 0;
|
||||
|
||||
const el =
|
||||
detail.selectable.getSibling(-1)?.getHtmlElement() ??
|
||||
detail.selectable?.getHtmlElement();
|
||||
|
||||
if (el) scrollElementIntoView(el, { top: 32 });
|
||||
}}
|
||||
on:select={() => handleItemAction(row)}
|
||||
on:click={() => {
|
||||
selectedRow = row;
|
||||
selectedActionIndex = 0;
|
||||
}}
|
||||
on:navigate={({ detail }) => {
|
||||
if (detail.direction === 'left') {
|
||||
selectedActionIndex = Math.max(0, selectedActionIndex - 1);
|
||||
} else if (detail.direction === 'right') {
|
||||
selectedActionIndex = Math.min(row.actions.length - 1, selectedActionIndex + 1);
|
||||
}
|
||||
}}
|
||||
focusOnClick
|
||||
let:hasFocus
|
||||
>
|
||||
<div class={classNames('cursor-pointer my-8 rounded-xl', {})}>
|
||||
<span
|
||||
class={classNames(
|
||||
'text-3xl font-semibold flex items-center',
|
||||
// 'text-secondary-200',
|
||||
{
|
||||
'text-secondary-500': !hasFocus,
|
||||
'text-secondary-100': hasFocus
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span class="line-clamp-1">
|
||||
{capitalize(row.label)}
|
||||
</span>
|
||||
<!-- {#if hasFocus}
|
||||
<Play class="w-8 h-8 ml-4" />
|
||||
{/if} -->
|
||||
</span>
|
||||
<span class="text-secondary-200">
|
||||
{row.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- <div class="h-[1px] w-full my-2 bg-secondary-400" /> -->
|
||||
{:else}
|
||||
<div class="h-ghost m-auto">No streams available</div>
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
</ActionsMenuContainer>
|
||||
@@ -1,4 +0,0 @@
|
||||
export type ViewItem = {
|
||||
label: string;
|
||||
handleClick: () => void;
|
||||
};
|
||||
@@ -1,18 +1,17 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Container from '../Container.svelte';
|
||||
import { modalStack } from './modal.store';
|
||||
import classNames from 'classnames';
|
||||
import { componentStackContext } from '../ComponentStack/component-stack.store';
|
||||
import { useComponentStack } from '../StackRouter/stack-router.store';
|
||||
|
||||
const { componentStack } = componentStackContext.getContext();
|
||||
const componentStack = useComponentStack();
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: null;
|
||||
}>();
|
||||
|
||||
function handleClose() {
|
||||
componentStack.pop();
|
||||
componentStack.close();
|
||||
dispatch('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import CompanyPage from '$lib/pages/CollectionPages/CompanyPage.svelte';
|
||||
import ListPage from '$lib/pages/CollectionPages/ListPage.svelte';
|
||||
import NetworkPage from '$lib/pages/CollectionPages/NetworkPage.svelte';
|
||||
import { Selectable } from '$lib/selectable';
|
||||
import { linkedListToArray } from '$lib/utils';
|
||||
import {
|
||||
getContext as getSvelteContext,
|
||||
SvelteComponentTyped,
|
||||
type ComponentProps,
|
||||
type ComponentType
|
||||
} from 'svelte';
|
||||
import { Selectable } from '$lib/selectable';
|
||||
import { getContext as getSvelteContext, SvelteComponentTyped, type ComponentType } from 'svelte';
|
||||
import { writable, type Readable, type Writable } from 'svelte/store';
|
||||
import LibraryPage from '../../pages/LibraryPage/LibraryPage.svelte';
|
||||
import ManagePage from '../../pages/ManagePage/ManagePage.svelte';
|
||||
@@ -23,17 +18,18 @@ import SeriesPage from '../../pages/TitlePages/SeriesPage/SeriesPage.svelte';
|
||||
import UiComponents from '../../pages/UIComponents.svelte';
|
||||
import UsersPage from '../../pages/UsersPage.svelte';
|
||||
|
||||
export type CreateCompStackPage<T extends SvelteComponentTyped = SvelteComponentTyped> = {
|
||||
component: ComponentType<T>;
|
||||
props: ComponentProps<T>;
|
||||
export type CreateCompStackPage<TProps extends Record<string, unknown> = Record<string, unknown>> =
|
||||
{
|
||||
component: ComponentType<SvelteComponentTyped<TProps>>;
|
||||
props: TProps;
|
||||
|
||||
id?: symbol;
|
||||
group?: symbol | 'top';
|
||||
id?: symbol;
|
||||
group?: symbol | 'top';
|
||||
|
||||
preventScroll?: boolean;
|
||||
trapFocus?: boolean;
|
||||
sidebar?: boolean;
|
||||
};
|
||||
preventScroll?: boolean;
|
||||
trapFocus?: boolean;
|
||||
sidebar?: boolean;
|
||||
};
|
||||
|
||||
export interface CompStackPage extends CreateCompStackPage {
|
||||
context: Record<string, unknown>;
|
||||
@@ -44,11 +40,12 @@ export interface CompStackPage extends CreateCompStackPage {
|
||||
setContext: <T = unknown>(key: string, value: T) => void;
|
||||
handleMount: () => void;
|
||||
close: () => void;
|
||||
push: (opts: CreateCompStackPage) => void;
|
||||
push: <TProps extends Record<string, unknown>>(opts: CreateCompStackPage<TProps>) => void;
|
||||
}
|
||||
|
||||
export interface CompStackForPageContext extends Readonly<CompStackPage> {
|
||||
readonly root: Readable<Selectable | undefined>;
|
||||
readonly hasFocusWithin: Readable<boolean>;
|
||||
}
|
||||
|
||||
class RouteGroup {
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Container from '$components/Container.svelte';
|
||||
import Tab from '../components/Tab/Tab.svelte';
|
||||
import Button from '../components/Button/Button.svelte';
|
||||
import { tmdbApi } from '../apis/tmdb/tmdb-api';
|
||||
import { ArrowLeft, ArrowRight, CheckCircled, ExternalLink } from 'radix-icons-svelte';
|
||||
import TextField from '../components/TextField.svelte';
|
||||
import { jellyfinApi, type JellyfinUser } from '../apis/jellyfin/jellyfin-api';
|
||||
import SelectField from '../components/SelectField.svelte';
|
||||
import SelectItem from '../components/SelectItem.svelte';
|
||||
import { sonarrApi } from '../apis/sonarr/sonarr-api';
|
||||
import { radarrApi } from '../apis/radarr/radarr-api';
|
||||
import { useTabs } from '../components/Tab/Tab';
|
||||
import classNames from 'classnames';
|
||||
import { user } from '../stores/user.store';
|
||||
import { sessions } from '../stores/session.store';
|
||||
import Panel from '../components/Panel.svelte';
|
||||
import TmdbIntegrationConnect from '../components/Integrations/TmdbIntegrationConnect.svelte';
|
||||
import JellyfinIntegration from '../components/Integrations/JellyfinIntegration.svelte';
|
||||
import SonarrIntegration from '../components/Integrations/SonarrIntegration.svelte';
|
||||
import RadarrIntegration from '../components/Integrations/RadarrIntegration.svelte';
|
||||
import TmdbIntegration from '../components/Integrations/TmdbIntegration.svelte';
|
||||
import TabContainer from '$lib/components/Tab/TabContainer.svelte';
|
||||
|
||||
enum Tabs {
|
||||
Welcome,
|
||||
Tmdb,
|
||||
Jellyfin,
|
||||
Sonarr,
|
||||
Radarr,
|
||||
Complete,
|
||||
|
||||
SelectUser = Jellyfin + 0.1,
|
||||
TmdbConnect = Tmdb + 0.1
|
||||
}
|
||||
|
||||
const tab = useTabs(Tabs.Welcome, { ['class']: 'w-max max-w-lg', remount: false });
|
||||
|
||||
$: connectedTmdbAccount = $user?.settings.tmdb.userId && tmdbApi.getAccountDetails();
|
||||
|
||||
let jellyfinUser: JellyfinUser | undefined = undefined;
|
||||
let jellyfinUsers: Promise<JellyfinUser[]> = Promise.resolve([]);
|
||||
|
||||
async function finalizeSetup() {
|
||||
await user.updateUser((prev) => ({
|
||||
...prev,
|
||||
onboardingDone: true
|
||||
}));
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
tab.previous();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container focusOnMount class="h-full w-full flex items-center justify-center" on:back={handleBack}>
|
||||
<Panel class="max-w-lg" size="dynamic">
|
||||
<TabContainer absolute>
|
||||
<Tab {...tab} tab={Tabs.Welcome} on:back={({ detail }) => detail.stopPropagation()}>
|
||||
<h1 class="h3 mb-2 w-full">Welcome to Reiverr</h1>
|
||||
<div class="body mb-8">
|
||||
Looks like this is a new account. This setup will get you started with connecting your
|
||||
services to get most out of Reiverr.
|
||||
</div>
|
||||
<Container direction="horizontal" class="flex space-x-4 *:flex-1">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => sessions.removeSession()}>
|
||||
Log Out
|
||||
</Button>
|
||||
<Button
|
||||
focusOnMount
|
||||
type="primary-dark"
|
||||
on:clickOrSelect={() => tab.next()}
|
||||
iconAbsolute={ArrowRight}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Container>
|
||||
</Tab>
|
||||
|
||||
<Tab {...tab} tab={Tabs.Tmdb}>
|
||||
<h1 class="h3 mb-2">Connect a TMDB Account</h1>
|
||||
<div class="body mb-8">
|
||||
Connect to TMDB for personalized recommendations based on your movie reviews and
|
||||
preferences.
|
||||
</div>
|
||||
|
||||
<TmdbIntegration handleConnectTmdb={() => tab.set(Tabs.TmdbConnect)} let:connected>
|
||||
{#if !connected}
|
||||
{#if !$user?.settings.tmdb.userId}
|
||||
<Button
|
||||
type="primary-dark"
|
||||
on:clickOrSelect={() => {
|
||||
tab.set(Tabs.TmdbConnect);
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
<Container direction="horizontal" class="flex space-x-4 *:flex-1 mt-4">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
|
||||
<Button focusedChild type="primary-dark" on:clickOrSelect={() => tab.next()}>
|
||||
{#if connected}
|
||||
Next
|
||||
{:else}
|
||||
Skip
|
||||
{/if}
|
||||
</Button>
|
||||
</Container>
|
||||
</TmdbIntegration>
|
||||
|
||||
<!-- <div class="space-y-4 flex flex-col">-->
|
||||
<!-- {#await connectedTmdbAccount then account}-->
|
||||
<!-- {#if account}-->
|
||||
<!-- <SelectField-->
|
||||
<!-- class="mb-4"-->
|
||||
<!-- value={account.username || ''}-->
|
||||
<!-- on:clickOrSelect={() => {-->
|
||||
<!-- tab.set(Tabs.TmdbConnect);-->
|
||||
<!-- }}>Logged in as</SelectField-->
|
||||
<!-- >-->
|
||||
<!-- {:else}-->
|
||||
<!-- <Button-->
|
||||
<!-- type="primary-dark"-->
|
||||
<!-- on:clickOrSelect={() => {-->
|
||||
<!-- tab.set(Tabs.TmdbConnect);-->
|
||||
<!-- }}-->
|
||||
<!-- >-->
|
||||
<!-- Connect-->
|
||||
<!-- <ArrowRight size={19} slot="icon-absolute" />-->
|
||||
<!-- </Button>-->
|
||||
<!-- {/if}-->
|
||||
<!-- {/await}-->
|
||||
<!-- </div>-->
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
{...tab}
|
||||
tab={Tabs.TmdbConnect}
|
||||
on:back={({ detail }) => {
|
||||
tab.set(Tabs.Tmdb);
|
||||
detail.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<TmdbIntegrationConnect on:connected={() => tab.set(Tabs.Jellyfin)} />
|
||||
</Tab>
|
||||
|
||||
<Tab {...tab} tab={Tabs.Jellyfin}>
|
||||
<h1 class="h3 mb-2">Connect to Jellyfin</h1>
|
||||
<div class="mb-8 body">Connect to Jellyfin to watch movies and tv shows.</div>
|
||||
|
||||
<JellyfinIntegration
|
||||
bind:jellyfinUser
|
||||
bind:jellyfinUsers
|
||||
on:click-user={() => tab.set(Tabs.SelectUser)}
|
||||
let:handleSave
|
||||
let:stale
|
||||
let:empty
|
||||
let:unchanged
|
||||
>
|
||||
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
|
||||
{#if empty || unchanged}
|
||||
<Button focusedChild type="primary-dark" on:clickOrSelect={() => tab.next()}>
|
||||
{empty ? 'Skip' : 'Next'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
type="primary-dark"
|
||||
disabled={!stale}
|
||||
action={() => handleSave().then(tab.next)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</JellyfinIntegration>
|
||||
|
||||
<!-- <div class="space-y-4 mb-4">-->
|
||||
<!-- <TextField bind:value={jellyfinBaseUrl} isValid={jellyfinUsers.then((u) => !!u?.length)}>-->
|
||||
<!-- Base Url-->
|
||||
<!-- </TextField>-->
|
||||
<!-- <TextField bind:value={jellyfinApiKey} isValid={jellyfinUsers.then((u) => !!u?.length)}>-->
|
||||
<!-- API Key-->
|
||||
<!-- </TextField>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- {#await jellyfinUsers then users}-->
|
||||
<!-- {#if users.length}-->
|
||||
<!-- <SelectField-->
|
||||
<!-- value={jellyfinUser?.Name || 'Select User'}-->
|
||||
<!-- on:clickOrSelect={() => tab.set(Tabs.SelectUser)}-->
|
||||
<!-- class="mb-4"-->
|
||||
<!-- >-->
|
||||
<!-- User-->
|
||||
<!-- </SelectField>-->
|
||||
<!-- {/if}-->
|
||||
<!-- {/await}-->
|
||||
|
||||
<!-- {#if jellyfinError}-->
|
||||
<!-- <div class="text-red-500 mb-4">{jellyfinError}</div>-->
|
||||
<!-- {/if}-->
|
||||
</Tab>
|
||||
<Tab
|
||||
{...tab}
|
||||
tab={Tabs.SelectUser}
|
||||
on:back={({ detail }) => {
|
||||
tab.set(Tabs.Jellyfin);
|
||||
detail.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<h1 class="h4 mb-2 w-96">Select User</h1>
|
||||
<div class="flex flex-col space-y-4" />
|
||||
{#await jellyfinUsers then users}
|
||||
{#each users || [] as user}
|
||||
<SelectItem
|
||||
selected={user?.Id === jellyfinUser?.Id}
|
||||
on:clickOrSelect={() => {
|
||||
jellyfinUser = user;
|
||||
tab.set(Tabs.Jellyfin);
|
||||
}}
|
||||
>
|
||||
{user.Name}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
{/await}
|
||||
</Tab>
|
||||
|
||||
<Tab {...tab} tab={Tabs.Sonarr}>
|
||||
<h1 class="h3 mb-2">Connect to Sonarr</h1>
|
||||
<div class="mb-8">Connect to Sonarr for requesting and managing tv shows.</div>
|
||||
|
||||
<SonarrIntegration let:stale let:handleSave let:empty let:unchanged>
|
||||
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
|
||||
{#if empty || unchanged}
|
||||
<Button focusedChild type="primary-dark" on:clickOrSelect={() => tab.next()}>
|
||||
{empty ? 'Skip' : 'Next'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
type="primary-dark"
|
||||
disabled={!stale}
|
||||
action={() => handleSave().then(tab.next)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</SonarrIntegration>
|
||||
</Tab>
|
||||
|
||||
<Tab {...tab} tab={Tabs.Radarr}>
|
||||
<h1 class="h3 mb-2">Connect to Radarr</h1>
|
||||
<div class="mb-8">Connect to Radarr for requesting and managing movies.</div>
|
||||
|
||||
<RadarrIntegration let:stale let:handleSave let:empty let:unchanged>
|
||||
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
|
||||
{#if empty || unchanged}
|
||||
<Button focusedChild type="primary-dark" on:clickOrSelect={() => tab.next()}>
|
||||
{empty ? 'Skip' : 'Next'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
type="primary-dark"
|
||||
disabled={!stale}
|
||||
action={() => handleSave().then(tab.next)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</RadarrIntegration>
|
||||
</Tab>
|
||||
|
||||
<Tab {...tab} tab={Tabs.Complete} class={classNames('w-full')}>
|
||||
<div class="flex items-center justify-center text-secondary-500 mb-4">
|
||||
<CheckCircled size={64} />
|
||||
</div>
|
||||
<h1 class="h3 text-center w-full">All Set!</h1>
|
||||
<div class="body mb-8 text-center">Reiverr is now ready to use.</div>
|
||||
|
||||
<Container direction="horizontal" class="inline-flex space-x-4 w-full">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()} icon={ArrowLeft}>
|
||||
Back
|
||||
</Button>
|
||||
<div class="flex-1">
|
||||
<Button
|
||||
focusedChild
|
||||
type="primary-dark"
|
||||
on:clickOrSelect={finalizeSetup}
|
||||
iconAbsolute={ArrowRight}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</Tab>
|
||||
</TabContainer>
|
||||
</Panel>
|
||||
</Container>
|
||||
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import { capitalize } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { ChevronRight } from 'radix-icons-svelte';
|
||||
import { breadcrumbsContext } from './actions-page';
|
||||
import ActionsMenuContainer from './ActionsMenuContainer.svelte';
|
||||
|
||||
type ViewItem = {
|
||||
label: string;
|
||||
handleClick: () => void;
|
||||
};
|
||||
|
||||
export let items: Promise<ViewItem[]>;
|
||||
export let name = '';
|
||||
|
||||
if (name) breadcrumbsContext.createContext(name);
|
||||
</script>
|
||||
|
||||
<ActionsMenuContainer class="space-y-4">
|
||||
{#await items}
|
||||
Loading...
|
||||
{:then items}
|
||||
{#each items as item}
|
||||
<Container on:clickOrSelect={item.handleClick} let:hasFocus>
|
||||
<span
|
||||
class={classNames(
|
||||
'px-8 py-4 rounded-xl',
|
||||
'cursor-pointer text-3xl font-semibold flex justify-between items-center',
|
||||
{
|
||||
'text-secondary-400 border-transparent': !hasFocus,
|
||||
'text-primary-100 bg-primary-200/10 border-primary-400': hasFocus
|
||||
}
|
||||
)}
|
||||
>
|
||||
{capitalize(item.label)}
|
||||
{#if hasFocus}
|
||||
<ChevronRight class="w-8 h-8 ml-4" />
|
||||
{/if}
|
||||
</span>
|
||||
</Container>
|
||||
{/each}
|
||||
{/await}
|
||||
</ActionsMenuContainer>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ChevronRight } from 'radix-icons-svelte';
|
||||
import { breadcrumbsContext, playableDataContext } from './actions-page';
|
||||
|
||||
const { tmdbMovie, tmdbSeries, season, episode } = playableDataContext.getContext();
|
||||
const { breadcrumbs } = breadcrumbsContext.getContext();
|
||||
</script>
|
||||
|
||||
<div class="mb-8">
|
||||
<!-- {#if tmdbMovie}
|
||||
{#await $tmdbMovie then tmdbMovie}
|
||||
<h2 class="h4">{tmdbMovie.title}</h2>
|
||||
{/await}
|
||||
{:else if tmdbSeries}
|
||||
{#await $tmdbSeries then tmdbSeries}
|
||||
<h2 class="h4">{tmdbSeries.name}</h2>
|
||||
{/await}
|
||||
{/if} -->
|
||||
<!-- <h1 class="h1">
|
||||
{#if breadcrumbs}
|
||||
{breadcrumbs[breadcrumbs.length - 1]}
|
||||
{/if}
|
||||
</h1> -->
|
||||
|
||||
<div class="flex items-center space-x-2 h3">
|
||||
{#if breadcrumbs}
|
||||
{#each breadcrumbs as bc, i}
|
||||
{@const last = i === breadcrumbs.length - 1}
|
||||
<h2>{bc}</h2>
|
||||
{#if !last}
|
||||
<ChevronRight size={24} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
MediaSourceDto,
|
||||
ViewBaseDto,
|
||||
ViewProviderDto
|
||||
} from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import { TMDB_BACKDROP_SMALLEST } from '$lib/constants';
|
||||
import ListMenu from '$lib/components/Menu/ListMenu.svelte';
|
||||
import { reiverrApi, user } from '$lib/stores/user.store';
|
||||
import ActionListMenu from './ActionListMenu.svelte';
|
||||
import { playableDataContext, titlePageContext } from './actions-page';
|
||||
|
||||
export let tmdbId: string;
|
||||
export let season: number | undefined = undefined;
|
||||
export let episode: number | undefined = undefined;
|
||||
|
||||
type ViewItem = {
|
||||
label: string;
|
||||
handleClick: () => void;
|
||||
};
|
||||
|
||||
const { componentStack } = titlePageContext.getContext();
|
||||
const {} = playableDataContext.createContext({ tmdbId, season, episode });
|
||||
const background = getBackgroundPage();
|
||||
|
||||
const views = getViews();
|
||||
|
||||
function getProvidersWithSources(providers: ViewProviderDto[]) {
|
||||
return providers
|
||||
.map((p) => ({
|
||||
...p,
|
||||
source: $user?.mediaSources.find((source) => source.id === p.sourceId) as MediaSourceDto
|
||||
}))
|
||||
.filter((p) => p.source);
|
||||
}
|
||||
|
||||
function createView(source: MediaSourceDto, view: ViewBaseDto) {
|
||||
if (view.type === 'list-with-details') {
|
||||
componentStack.create(ListMenu, {
|
||||
viewBase: view,
|
||||
source,
|
||||
name: source.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getViews(): Promise<ViewItem[]> {
|
||||
const { viewGroups } = await reiverrApi.sources
|
||||
.getMediaSourceViewGroups({ tmdbId, season, episode })
|
||||
.then((r) => r.data);
|
||||
|
||||
const views: ViewItem[] = [];
|
||||
|
||||
for (const group of viewGroups) {
|
||||
const providersWithSources = getProvidersWithSources(group.viewProviders);
|
||||
|
||||
if (!providersWithSources.length) continue;
|
||||
|
||||
views.push({
|
||||
label: group.label,
|
||||
handleClick: () => {
|
||||
if (providersWithSources.length > 1) {
|
||||
const items: ViewItem[] = providersWithSources.map((p) => ({
|
||||
label: p.source.name,
|
||||
handleClick: () => {
|
||||
createView(p.source, p.view);
|
||||
}
|
||||
}));
|
||||
|
||||
componentStack.create(ActionListMenu, {
|
||||
items: Promise.resolve(items),
|
||||
name: 'Sources'
|
||||
});
|
||||
} else {
|
||||
const viewProvider = providersWithSources[0];
|
||||
if (!viewProvider) return;
|
||||
|
||||
createView(viewProvider.source, viewProvider.view);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
views.push({
|
||||
label: 'Mark as watched',
|
||||
handleClick: () => {}
|
||||
});
|
||||
|
||||
return views;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This is because of the limited support for css backdrop blur and performance cost of blurring 4k backdrop -->
|
||||
<div class="fixed inset-0 scale-110 z-[21]">
|
||||
<div
|
||||
class="absolute inset-0 bg-center bg-cover bg-no-repeat blur-md brightness-[0.2] saturate-50"
|
||||
style={$background?.backdropUri
|
||||
? `background-image: url('${TMDB_BACKDROP_SMALLEST}${$background?.backdropUri}');`
|
||||
: ''}
|
||||
/>
|
||||
</div>
|
||||
<ActionListMenu items={views} name={tmdbId} />
|
||||
@@ -1,30 +0,0 @@
|
||||
<script>
|
||||
import ComponentStackContainer from '$lib/components/ComponentStack/ComponentStackContainer.svelte';
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import ActionPageTitle from '$lib/pages/TitlePages/ActionsPage/ActionPageTitle.svelte';
|
||||
import { titlePageContext } from '$lib/pages/TitlePages/ActionsPage/actions-page';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const { componentStack } = titlePageContext.getContext();
|
||||
</script>
|
||||
|
||||
<ComponentStackContainer trapFocus sidebar={false}>
|
||||
<Container
|
||||
class={classNames(
|
||||
'pt-16 px-32 flex flex-col min-h-screen bg-primary-900/50',
|
||||
$$restProps.class
|
||||
)}
|
||||
on:back={({ detail }) => {
|
||||
componentStack.pop();
|
||||
detail.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<ActionPageTitle />
|
||||
<div
|
||||
class="overflow-y-auto overflow-x-hidden scrollbar-hide pb-16 mx-32"
|
||||
style="backface-visibility: hidden"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Container>
|
||||
</ComponentStackContainer>
|
||||
@@ -1,142 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ListWithDetailsItemDto, MediaSourceDto } from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import Button from '$lib/components/Button/Button.svelte';
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import { breadcrumbsContext } from '$lib/pages/TitlePages/ActionsPage/actions-page';
|
||||
import { scrollElementIntoView } from '$lib/scroll-into-view';
|
||||
import { capitalize } from '$lib/utils';
|
||||
import type { MediaSourceProvider } from '@aleksilassila/reiverr-shared';
|
||||
import classNames from 'classnames';
|
||||
import ActionsMenuContainer from './ActionsMenuContainer.svelte';
|
||||
|
||||
type Row = ListWithDetailsItemDto;
|
||||
type Items = {
|
||||
source: MediaSourceDto;
|
||||
streams: Row[];
|
||||
};
|
||||
|
||||
export let items: Items[];
|
||||
|
||||
// export let viewBase: ViewBaseDto;
|
||||
// export let source: MediaSourceDto;
|
||||
export let handleClickItem: (opts: { item: Row; source: MediaSourceDto }) => void | Promise<void>;
|
||||
export let handleShowAll: ({ source }: { source: MediaSourceDto }) => void | Promise<void>;
|
||||
export let name = '';
|
||||
|
||||
// const { tmdbId, season, episode, playStream, handleAction, handleOpenView } =
|
||||
// playableDataContext.getContext();
|
||||
if (name) breadcrumbsContext.createContext(name);
|
||||
|
||||
let selectedRow: Row;
|
||||
let selectedActionIndex = 0;
|
||||
// let actionsLoading = false;
|
||||
</script>
|
||||
|
||||
<ActionsMenuContainer class="h-screen !px-0 [&>*:first-child]:px-32">
|
||||
<!-- {#if selectedRow}
|
||||
{#if selectedRow}
|
||||
<div
|
||||
class="space-y-4 rounded-2xl h-52 flex-shrink-0 flex flex-col justify-between bg-primary-200/10 p-8 mx-24"
|
||||
>
|
||||
<div>
|
||||
{#key selectedRow.label}
|
||||
<Marquee class="text-3xl font-semibold text-primary-100">
|
||||
{capitalize(selectedRow.label)}
|
||||
</Marquee>
|
||||
{/key}
|
||||
|
||||
<span class="text-secondary-200 line-clamp-2">
|
||||
{selectedRow.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
{#each selectedRow.actions as action, index}
|
||||
{@const selected = index === selectedActionIndex}
|
||||
<div
|
||||
class={classNames(
|
||||
'inline-flex items-center font-medium tracking-wide h-12 ',
|
||||
'group rounded-xl px-6 bg-primary-900',
|
||||
'border-2 p-1 hover:border-primary-500',
|
||||
{
|
||||
'text-primary-100 border-transparent': !selected,
|
||||
'text-primary-100 border-primary-400': selected,
|
||||
'cursor-pointer': !(action.type === 'action' && action.disabled)
|
||||
}
|
||||
)}
|
||||
>
|
||||
{#if action.type === 'action' && action.action === 'stream'}
|
||||
<TriangleRight size={28} class="-ml-2 mr-1" />
|
||||
{/if}
|
||||
|
||||
{action.label}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if} -->
|
||||
|
||||
<div
|
||||
class="overflow-y-auto overflow-x-hidden scrollbar-hide pb-16 mx-32"
|
||||
style="backface-visibility: hidden"
|
||||
>
|
||||
{#each items as { streams, source }}
|
||||
{#each streams as row, index}
|
||||
<Container
|
||||
on:enter={({ detail }) => {
|
||||
selectedRow = row;
|
||||
selectedActionIndex = 0;
|
||||
|
||||
const el =
|
||||
detail.selectable.getSibling(-1)?.getHtmlElement() ??
|
||||
detail.selectable?.getHtmlElement();
|
||||
|
||||
if (el) scrollElementIntoView(el, { top: 32 });
|
||||
}}
|
||||
on:select={() => handleClickItem({ item: row, source })}
|
||||
on:click={() => {
|
||||
selectedRow = row;
|
||||
selectedActionIndex = 0;
|
||||
}}
|
||||
on:navigate={({ detail }) => {
|
||||
if (detail.direction === 'left') {
|
||||
selectedActionIndex = Math.max(0, selectedActionIndex - 1);
|
||||
} else if (detail.direction === 'right') {
|
||||
selectedActionIndex = Math.min(row.actions.length - 1, selectedActionIndex + 1);
|
||||
}
|
||||
}}
|
||||
focusOnClick
|
||||
let:hasFocus
|
||||
>
|
||||
<div class={classNames('cursor-pointer my-8 rounded-xl', {})}>
|
||||
<span
|
||||
class={classNames(
|
||||
'text-3xl font-semibold flex items-center',
|
||||
// 'text-secondary-200',
|
||||
{
|
||||
'text-secondary-500': !hasFocus,
|
||||
'text-secondary-100': hasFocus
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span class="line-clamp-1">
|
||||
{capitalize(row.label)}
|
||||
</span>
|
||||
<!-- {#if hasFocus}
|
||||
<Play class="w-8 h-8 ml-4" />
|
||||
{/if} -->
|
||||
</span>
|
||||
<span class="text-secondary-200">
|
||||
{row.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- <div class="h-[1px] w-full my-2 bg-secondary-400" /> -->
|
||||
{:else}
|
||||
<div class="h-ghost m-auto">No streams available</div>
|
||||
{/each}
|
||||
<Button on:clickOrSelect={() => handleShowAll({ source })}>View all</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</ActionsMenuContainer>
|
||||
@@ -1,155 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
ListWithDetailsItemDto,
|
||||
MediaSourceDto,
|
||||
ProviderWithStreamsDto
|
||||
} from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import type { TmdbEpisode, TmdbSeries } from '$lib/apis/tmdb/tmdb-api';
|
||||
import ComponentStackContainer from '$lib/components/ComponentStack/ComponentStackContainer.svelte';
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import { TMDB_BACKDROP_SMALLEST } from '$lib/constants';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import { capitalize, toNonNullable } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { breadcrumbsContext, playableDataContext, titlePageContext } from './actions-page';
|
||||
|
||||
type Row = ListWithDetailsItemDto;
|
||||
type Items = {
|
||||
source: MediaSourceDto;
|
||||
streams: Row[];
|
||||
};
|
||||
|
||||
export let items: ProviderWithStreamsDto[];
|
||||
export let series: TmdbSeries;
|
||||
export let episode: TmdbEpisode;
|
||||
// export let tmdbId: string;
|
||||
// export let season: number;
|
||||
// export let episode: number;
|
||||
|
||||
const background = getBackgroundPage();
|
||||
|
||||
// const {} = playableDataContext.createContext({ tmdbId, season, episode });
|
||||
const { playStream } = playableDataContext.createContext({
|
||||
tmdbId: `${series.id}`,
|
||||
season: episode.season_number ?? 0,
|
||||
episode: episode.episode_number ?? 0
|
||||
});
|
||||
|
||||
const { componentStack } = titlePageContext.getContext();
|
||||
const { breadcrumbs } = breadcrumbsContext.createContext('asd');
|
||||
|
||||
function handleClickItem({ item, source }: { item: Row; source: MediaSourceDto }) {}
|
||||
function handleShowAll({ source }: { source: MediaSourceDto }) {}
|
||||
|
||||
let selectedProvider: ProviderWithStreamsDto | undefined = items[0];
|
||||
</script>
|
||||
|
||||
<!-- <TheListContainer
|
||||
items={items.map((i) => ({
|
||||
...i,
|
||||
source: i.provider,
|
||||
streams: i.streams.map((s) => ({
|
||||
...s,
|
||||
id: s.streamId,
|
||||
label: s.title,
|
||||
description: '',
|
||||
properties: [],
|
||||
actions: []
|
||||
}))
|
||||
}))}
|
||||
{handleClickItem}
|
||||
{handleShowAll}
|
||||
/> -->
|
||||
|
||||
<div class="fixed inset-0 scale-110 z-[21]">
|
||||
<div
|
||||
class="absolute inset-0 bg-center bg-cover bg-no-repeat blur-md brightness-[0.2] saturate-50"
|
||||
style={$background?.backdropUri
|
||||
? `background-image: url('${TMDB_BACKDROP_SMALLEST}${$background?.backdropUri}');`
|
||||
: ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ComponentStackContainer trapFocus sidebar={false}>
|
||||
<Container
|
||||
class={classNames('px-32 flex flex-col h-screen bg-primary-900/50', $$restProps.class)}
|
||||
on:back={({ detail }) => {
|
||||
componentStack.pop();
|
||||
detail.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Container class="flex items-center max-w-screen py-4" direction="horizontal">
|
||||
<div
|
||||
class="*:text-ellipsis *:text-nowrap *:overflow-hidden overflow-hidden flex-grow flex-shrink"
|
||||
>
|
||||
<p class="body">
|
||||
{series.name}
|
||||
{`S${episode.season_number}`} E{episode.episode_number}
|
||||
</p>
|
||||
<h2 class="h2">
|
||||
{episode.name}
|
||||
</h2>
|
||||
</div>
|
||||
{#each items as provider}
|
||||
<Container
|
||||
on:clickOrSelect={() => (selectedProvider = provider)}
|
||||
class="mx-4 cursor-pointer flex-shrink-0 h3"
|
||||
let:hasFocus
|
||||
>
|
||||
<span
|
||||
class={classNames({ 'text-secondary-100': hasFocus, 'text-secondary-500': !hasFocus })}
|
||||
>
|
||||
{provider.provider.name} ({provider.streams.length})
|
||||
</span>
|
||||
</Container>
|
||||
{/each}
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
class="flex-1 overflow-y-auto overflow-x-hidden scrollbar-hide"
|
||||
style="backface-visibility: hidden"
|
||||
focusOnMount
|
||||
>
|
||||
{#each selectedProvider?.streams ?? [] as row, index}
|
||||
<Container
|
||||
on:enter={scrollIntoView({ top: 32 })}
|
||||
focusOnClick
|
||||
let:hasFocus
|
||||
on:clickOrSelect={() =>
|
||||
playStream({
|
||||
source: toNonNullable(selectedProvider).provider,
|
||||
streamId: row.streamId
|
||||
})}
|
||||
>
|
||||
<div class={classNames('cursor-pointer my-8 rounded-xl', {})}>
|
||||
<span
|
||||
class={classNames(
|
||||
'text-3xl font-semibold flex items-center',
|
||||
// 'text-secondary-200',
|
||||
{
|
||||
'text-secondary-500': !hasFocus,
|
||||
'text-secondary-100': hasFocus
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span class="line-clamp-1">
|
||||
{capitalize(row.title)}
|
||||
</span>
|
||||
<!-- {#if hasFocus}
|
||||
<Play class="w-8 h-8 ml-4" />
|
||||
{/if} -->
|
||||
</span>
|
||||
<span class="text-secondary-200">
|
||||
{row.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- <div class="h-[1px] w-full my-2 bg-secondary-400" /> -->
|
||||
{:else}
|
||||
<div class="h-ghost m-auto">No streams available</div>
|
||||
{/each}
|
||||
</Container>
|
||||
</Container>
|
||||
</ComponentStackContainer>
|
||||
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ListWithDetailsItemDto, MediaSourceDto } from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import Button from '$lib/components/Button/Button.svelte';
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import { breadcrumbsContext } from '$lib/pages/TitlePages/ActionsPage/actions-page';
|
||||
import { scrollElementIntoView } from '$lib/scroll-into-view';
|
||||
import { capitalize } from '$lib/utils';
|
||||
import type { MediaSourceProvider } from '@aleksilassila/reiverr-shared';
|
||||
import classNames from 'classnames';
|
||||
import ActionsMenuContainer from './ActionsMenuContainer.svelte';
|
||||
|
||||
type Row = ListWithDetailsItemDto;
|
||||
type Items = {
|
||||
source: MediaSourceDto;
|
||||
streams: Row[];
|
||||
};
|
||||
|
||||
export let items: Items[];
|
||||
|
||||
// export let viewBase: ViewBaseDto;
|
||||
// export let source: MediaSourceDto;
|
||||
export let handleClickItem: (opts: { item: Row; source: MediaSourceDto }) => void | Promise<void>;
|
||||
export let handleShowAll: ({ source }: { source: MediaSourceDto }) => void | Promise<void>;
|
||||
export let name = '';
|
||||
|
||||
// const { tmdbId, season, episode, playStream, handleAction, handleOpenView } =
|
||||
// playableDataContext.getContext();
|
||||
if (name) breadcrumbsContext.createContext(name);
|
||||
|
||||
let selectedRow: Row;
|
||||
let selectedActionIndex = 0;
|
||||
// let actionsLoading = false;
|
||||
</script>
|
||||
|
||||
<ActionsMenuContainer class="h-screen !px-0 [&>*:first-child]:px-32">
|
||||
<div
|
||||
class="overflow-y-auto overflow-x-hidden scrollbar-hide pb-16 mx-32"
|
||||
style="backface-visibility: hidden"
|
||||
>
|
||||
{#each items as { streams, source }}
|
||||
<div>
|
||||
<h1>{source.name}</h1>
|
||||
</div>
|
||||
|
||||
{#each streams as row, index}
|
||||
<Container
|
||||
on:enter={({ detail }) => {
|
||||
selectedRow = row;
|
||||
selectedActionIndex = 0;
|
||||
|
||||
const el =
|
||||
detail.selectable.getSibling(-1)?.getHtmlElement() ??
|
||||
detail.selectable?.getHtmlElement();
|
||||
|
||||
if (el) scrollElementIntoView(el, { top: 32 });
|
||||
}}
|
||||
on:select={() => handleClickItem({ item: row, source })}
|
||||
on:click={() => {
|
||||
selectedRow = row;
|
||||
selectedActionIndex = 0;
|
||||
}}
|
||||
on:navigate={({ detail }) => {
|
||||
if (detail.direction === 'left') {
|
||||
selectedActionIndex = Math.max(0, selectedActionIndex - 1);
|
||||
} else if (detail.direction === 'right') {
|
||||
selectedActionIndex = Math.min(row.actions.length - 1, selectedActionIndex + 1);
|
||||
}
|
||||
}}
|
||||
focusOnClick
|
||||
let:hasFocus
|
||||
>
|
||||
<div class={classNames('cursor-pointer my-8 rounded-xl', {})}>
|
||||
<span
|
||||
class={classNames(
|
||||
'text-3xl font-semibold flex items-center',
|
||||
// 'text-secondary-200',
|
||||
{
|
||||
'text-secondary-500': !hasFocus,
|
||||
'text-secondary-100': hasFocus
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span class="line-clamp-1">
|
||||
{capitalize(row.label)}
|
||||
</span>
|
||||
<!-- {#if hasFocus}
|
||||
<Play class="w-8 h-8 ml-4" />
|
||||
{/if} -->
|
||||
</span>
|
||||
<span class="text-secondary-200">
|
||||
{row.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- <div class="h-[1px] w-full my-2 bg-secondary-400" /> -->
|
||||
{:else}
|
||||
<div class="h-ghost m-auto">No streams available</div>
|
||||
{/each}
|
||||
<Button on:clickOrSelect={() => handleShowAll({ source })}>View all</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</ActionsMenuContainer>
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
createErrorNotification,
|
||||
createInfoNotification
|
||||
} from '$lib/components/Notifications/notification.store';
|
||||
import { componentStackContext } from '$lib/stores/component-stack.store';
|
||||
import {
|
||||
getContext,
|
||||
hasContext,
|
||||
useComponentStack
|
||||
} from '$lib/components/StackRouter/stack-router.store';
|
||||
import {
|
||||
TITLE_USER_DATA_CONTEXT,
|
||||
type TitleUserData
|
||||
@@ -11,9 +15,6 @@ import {
|
||||
import { reiverrApi } from '$lib/stores/user.store';
|
||||
import { createStoreContext } from '$lib/utils';
|
||||
import { writable } from 'svelte/store';
|
||||
import ManageMenu from '../ManageMenu.svelte';
|
||||
import ActionsMenu from './ActionsMenu.svelte';
|
||||
import { getContext, hasContext } from '$lib/components/ComponentStack/component-stack.store';
|
||||
|
||||
function usePlayableDataStore(options: { tmdbId: string; season?: number; episode?: number }) {
|
||||
const { tmdbId, season, episode } = options;
|
||||
@@ -49,28 +50,34 @@ function usePlayableDataStore(options: { tmdbId: string; season?: number; episod
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
function useTitlePage() {
|
||||
const componentStack = componentStackContext.createContext();
|
||||
// /** @deprecated */
|
||||
// function useTitlePage() {
|
||||
// const componentStack = useComponentStack();
|
||||
|
||||
const openEpisodeMenu = (tmdbId: string, season: number, episode: number) =>
|
||||
componentStack.create(ActionsMenu, {
|
||||
tmdbId,
|
||||
season,
|
||||
episode
|
||||
});
|
||||
// const openEpisodeMenu = (tmdbId: string, season: number, episode: number) => {};
|
||||
// // componentStack.push({
|
||||
// // component: ActionsMenu,
|
||||
// // props: {
|
||||
// // tmdbId,
|
||||
// // season,
|
||||
// // episode
|
||||
// // }
|
||||
// // });
|
||||
|
||||
const openManageSeries = (tmdbId: string) =>
|
||||
componentStack.create(ManageMenu, {
|
||||
tmdbId
|
||||
});
|
||||
// const openManageSeries = (tmdbId: string) => {};
|
||||
// // componentStack.push({
|
||||
// // component: ManageMenu,
|
||||
// // props: {
|
||||
// // tmdbId
|
||||
// // }
|
||||
// // });
|
||||
|
||||
return {
|
||||
componentStack,
|
||||
openEpisodeMenu,
|
||||
openManageSeries
|
||||
};
|
||||
}
|
||||
// return {
|
||||
// componentStack,
|
||||
// openEpisodeMenu,
|
||||
// openManageSeries
|
||||
// };
|
||||
// }
|
||||
|
||||
export const playableDataContext = createStoreContext(
|
||||
'actions-page-context',
|
||||
@@ -98,8 +105,3 @@ export const breadcrumbsContext = createStoreContext(BREADCRUMBS_CONTEXT, (bc: s
|
||||
export const mediaSourceContext = createStoreContext('media-source', () =>
|
||||
writable<MediaSourceDto | undefined>(undefined)
|
||||
);
|
||||
|
||||
/** @deprecated */
|
||||
export const titlePageContext = createStoreContext('title-page', useTitlePage, {
|
||||
required: true
|
||||
});
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { MediaSourceDto, ViewBaseDto } from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import ListMenu from '$lib/components/Menu/ListMenu.svelte';
|
||||
import type { ViewItem } from '$lib/components/Menu/menu.types';
|
||||
import { TMDB_BACKDROP_SMALLEST } from '$lib/constants';
|
||||
import ActionListMenu from './ActionsPage/ActionListMenu.svelte';
|
||||
import { titlePageContext } from './ActionsPage/actions-page';
|
||||
|
||||
export let tmdbId: string;
|
||||
|
||||
const { componentStack } = titlePageContext.getContext();
|
||||
const background = getBackgroundPage();
|
||||
|
||||
const views = getViews();
|
||||
|
||||
function createView(source: MediaSourceDto, view: ViewBaseDto) {
|
||||
if (view.type === 'list-with-details') {
|
||||
componentStack.create(ListMenu, {
|
||||
viewBase: view,
|
||||
source,
|
||||
name: source.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getViews(): Promise<ViewItem[]> {
|
||||
// const { viewGroups } = await reiverrApi.sources
|
||||
// .getMediaSourceViewGroups({ tmdbId, season, episode })
|
||||
// .then((r) => r.data);
|
||||
|
||||
const views: ViewItem[] = [];
|
||||
|
||||
// for (const group of viewGroups) {
|
||||
// const providersWithSources = getProvidersWithSources(group.viewProviders);
|
||||
|
||||
// if (!providersWithSources.length) continue;
|
||||
|
||||
// views.push({
|
||||
// label: group.label,
|
||||
// handleClick: () => {
|
||||
// if (providersWithSources.length > 1) {
|
||||
// const items: ViewItem[] = providersWithSources.map((p) => ({
|
||||
// label: p.source.name,
|
||||
// handleClick: () => {
|
||||
// createView(p.source, p.view);
|
||||
// }
|
||||
// }));
|
||||
|
||||
// componentStack.create(ActionListMenu, {
|
||||
// items: Promise.resolve(items),
|
||||
// name: 'Sources'
|
||||
// });
|
||||
// } else {
|
||||
// const viewProvider = providersWithSources[0];
|
||||
// if (!viewProvider) return;
|
||||
|
||||
// createView(viewProvider.source, viewProvider.view);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
views.push({
|
||||
label: 'Mark as watched',
|
||||
handleClick: () => {}
|
||||
});
|
||||
|
||||
views.push({
|
||||
label: 'Add to library',
|
||||
handleClick: () => {}
|
||||
});
|
||||
|
||||
return views;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This is because of the limited support for css backdrop blur and performance cost of blurring 4k backdrop -->
|
||||
<div class="fixed inset-0 scale-110 z-[21]">
|
||||
<div
|
||||
class="absolute inset-0 bg-center bg-cover bg-no-repeat blur-md brightness-[0.2] saturate-50"
|
||||
style={$background?.backdropUri
|
||||
? `background-image: url('${TMDB_BACKDROP_SMALLEST}${$background?.backdropUri}');`
|
||||
: ''}
|
||||
/>
|
||||
</div>
|
||||
<ActionListMenu items={views} name={tmdbId} />
|
||||
@@ -1,18 +1,43 @@
|
||||
<script lang="ts">
|
||||
import ComponentStack from '$lib/components/ComponentStack/ComponentStack.svelte';
|
||||
import Container from '$components/Container.svelte';
|
||||
import Button from '$lib/components/Button/Button.svelte';
|
||||
import TmdbCard from '$lib/components/Card/TmdbCard.svelte';
|
||||
import Carousel from '$lib/components/Carousel/Carousel.svelte';
|
||||
import { createBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import HeroCarousel from '$lib/components/HeroShowcase/HeroCarousel.svelte';
|
||||
import TmdbPersonCard from '$lib/components/PersonCard/TmdbPersonCard.svelte';
|
||||
import { PLATFORM_WEB } from '$lib/constants';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import { localSettings } from '$lib/stores/localstorage.store';
|
||||
import { setScrollContext } from '$lib/stores/scroll.store';
|
||||
import { setUiVisibilityContext } from '$lib/stores/ui-visibility.store';
|
||||
import { movieUserDataContext } from '$lib/stores/user-data/title-user-data.store';
|
||||
import { tmdbApi } from '$lib/stores/user.store';
|
||||
import { formatMinutesToTime, formatThousands } from '$lib/utils';
|
||||
import { Bookmark, Check, ExternalLink, Minus, Play, Video } from 'radix-icons-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { titlePageContext } from '../ActionsPage/actions-page';
|
||||
import MoviePageDetails from './MoviePageDetails.svelte';
|
||||
import type { TitleInfoProperty } from '../HeroTitleInfo';
|
||||
import HeroTitleInfo from '../HeroTitleInfo.svelte';
|
||||
|
||||
export let id: string;
|
||||
|
||||
const background = createBackgroundPage({ backgroundMediaId: id, videoMediaId: id });
|
||||
const { tmdbMovie, unsubscribe } = movieUserDataContext.createContext(id);
|
||||
const { componentStack } = titlePageContext.createContext();
|
||||
const {
|
||||
tmdbId,
|
||||
tmdbMovie,
|
||||
inLibrary,
|
||||
progress,
|
||||
handleAddToLibrary,
|
||||
handleRemoveFromLibrary,
|
||||
isWatched,
|
||||
toggleIsWatched,
|
||||
autoplayCandidate,
|
||||
autoplayStream,
|
||||
unsubscribe
|
||||
} = movieUserDataContext.createContext(id);
|
||||
// const { componentStack } = titlePageContext.createContext();
|
||||
|
||||
componentStack.push({ component: MoviePageDetails, props: {} });
|
||||
// componentStack.push({ component: MoviePageDetails, props: {} });
|
||||
|
||||
$tmdbMovie.then(async (movie) => {
|
||||
const backgrounds =
|
||||
@@ -34,9 +59,191 @@
|
||||
]);
|
||||
});
|
||||
|
||||
const { visibleStyle } = setUiVisibilityContext();
|
||||
const { registrar: registerScroll } = setScrollContext();
|
||||
|
||||
let trailerId: string | undefined;
|
||||
let titleProperties: TitleInfoProperty[] = [];
|
||||
|
||||
$: recommendations = tmdbApi.v3.movieRecommendations(Number(tmdbId)).then((r) => r.data.results);
|
||||
|
||||
$tmdbMovie.then(async (movie) => {
|
||||
trailerId = movie?.videos?.results?.find(
|
||||
(video) => video.type === 'Trailer' && video.site === 'YouTube'
|
||||
)?.key;
|
||||
|
||||
if (movie?.runtime) {
|
||||
titleProperties.push({
|
||||
label: formatMinutesToTime(movie.runtime)
|
||||
});
|
||||
}
|
||||
|
||||
if (movie?.vote_average) {
|
||||
titleProperties.push({
|
||||
label: `${movie.vote_average.toFixed(1)} TMDB (${formatThousands(movie.vote_count ?? 0)})`,
|
||||
href: `https://www.themoviedb.org/movie/${movie.id}`
|
||||
});
|
||||
}
|
||||
|
||||
if (movie?.genres) {
|
||||
titleProperties.push({
|
||||
label: movie.genres.map((g) => g.name).join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
if ($localSettings.enableTrailers && trailerId) {
|
||||
titleProperties.push({
|
||||
icon: Video,
|
||||
href: `https://www.youtube.com/watch?v=${trailerId}`
|
||||
});
|
||||
}
|
||||
|
||||
titleProperties = titleProperties;
|
||||
});
|
||||
$: if ($localSettings.autoplayTrailers && trailerId) {
|
||||
background?.playYoutubeVideo({ tmdbId, videoId: trailerId, onBackground: true });
|
||||
}
|
||||
|
||||
function openEpisodeMenu() {
|
||||
// componentStack.create(ActionsMenu, {
|
||||
// tmdbId
|
||||
// });
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<ComponentStack {componentStack} />
|
||||
<div class="relative" use:registerScroll>
|
||||
<Container
|
||||
class="h-[calc(100vh-4rem)] flex flex-col py-16 px-32"
|
||||
on:enter={scrollIntoView({ top: 999 })}
|
||||
>
|
||||
<HeroCarousel>
|
||||
{#await $tmdbMovie then movie}
|
||||
{#if movie}
|
||||
<HeroTitleInfo
|
||||
title={`${movie.title} (${new Date(movie.release_date ?? 0).getFullYear()})`}
|
||||
properties={titleProperties}
|
||||
overview={movie.overview ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
<Container direction="horizontal" class="flex mt-8 space-x-4" focusOnMount>
|
||||
<Button
|
||||
action={autoplayStream}
|
||||
secondaryAction={openEpisodeMenu}
|
||||
disabled={!$autoplayCandidate.candidate}
|
||||
>
|
||||
Play
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
|
||||
{#if trailerId}
|
||||
<Button
|
||||
on:clickOrSelect={() =>
|
||||
trailerId && background?.playYoutubeVideo({ tmdbId, videoId: trailerId })}
|
||||
>
|
||||
<Video slot="icon" size={19} />
|
||||
Play Trailer
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if !$inLibrary}
|
||||
<Button action={handleAddToLibrary} icon={Bookmark}>Add to Library</Button>
|
||||
{:else}
|
||||
<Button action={handleRemoveFromLibrary} icon={Minus}>Remove from Library</Button>
|
||||
{/if}
|
||||
|
||||
<Button action={toggleIsWatched}>
|
||||
{#if $isWatched}
|
||||
Mark as Unwatched
|
||||
{:else}
|
||||
Mark as Watched
|
||||
{/if}
|
||||
<Check slot="icon" size={19} />
|
||||
</Button>
|
||||
|
||||
{#if PLATFORM_WEB}
|
||||
<Button
|
||||
on:clickOrSelect={() =>
|
||||
window.open('https://www.themoviedb.org/movie/' + tmdbId, '_blank')}
|
||||
>
|
||||
Open In TMDB
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</HeroCarousel>
|
||||
</Container>
|
||||
<div class="relative z-10" style={$visibleStyle}>
|
||||
<Container on:enter={scrollIntoView({ top: 0 })} class="">
|
||||
{#await $tmdbMovie then movie}
|
||||
<Carousel scrollClass="px-32" class="mb-16">
|
||||
<div slot="header">Show Cast</div>
|
||||
{#each movie?.credits?.cast?.slice(0, 15) || [] as credit}
|
||||
<TmdbPersonCard on:enter={scrollIntoView({ left: 128 })} tmdbCredit={credit} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/await}
|
||||
{#await recommendations then recommendations}
|
||||
<Carousel scrollClass="px-32" class="mb-16">
|
||||
<div slot="header">Recommendations</div>
|
||||
{#each recommendations || [] as recommendation}
|
||||
<TmdbCard item={recommendation} on:enter={scrollIntoView({ left: 128 })} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/await}
|
||||
</Container>
|
||||
{#await $tmdbMovie then movie}
|
||||
<Container class="flex-1 bg-secondary-950 pt-8 px-32" on:enter={scrollIntoView({ top: 0 })}>
|
||||
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1>
|
||||
<div class="text-zinc-300 font-medium text-lg flex flex-wrap">
|
||||
<div class="flex-1">
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Directed By</h2>
|
||||
<div>
|
||||
{movie?.credits.crew
|
||||
?.filter((c) => c.job === 'Director')
|
||||
?.map((c) => c.name)
|
||||
.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Written By</h2>
|
||||
<div>
|
||||
{movie?.credits.crew
|
||||
?.filter((c) => c.job === 'Writer')
|
||||
?.map((c) => c.name)
|
||||
.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Languages</h2>
|
||||
<div>
|
||||
{movie?.spoken_languages?.map((language) => language.name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Release Date</h2>
|
||||
<div>
|
||||
{new Date(movie?.release_date || 0).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Container from '$components/Container.svelte';
|
||||
import Button from '$lib/components/Button/Button.svelte';
|
||||
import TmdbCard from '$lib/components/Card/TmdbCard.svelte';
|
||||
import Carousel from '$lib/components/Carousel/Carousel.svelte';
|
||||
import ComponentStackContainer from '$lib/components/ComponentStack/ComponentStackContainer.svelte';
|
||||
import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import HeroCarousel from '$lib/components/HeroShowcase/HeroCarousel.svelte';
|
||||
import TmdbPersonCard from '$lib/components/PersonCard/TmdbPersonCard.svelte';
|
||||
import { PLATFORM_WEB } from '$lib/constants';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import { localSettings } from '$lib/stores/localstorage.store';
|
||||
import { setScrollContext } from '$lib/stores/scroll.store';
|
||||
import { setUiVisibilityContext } from '$lib/stores/ui-visibility.store';
|
||||
import { movieUserDataContext } from '$lib/stores/user-data/title-user-data.store';
|
||||
import { tmdbApi } from '$lib/stores/user.store';
|
||||
import { formatMinutesToTime, formatThousands } from '$lib/utils';
|
||||
import { Bookmark, Check, ExternalLink, Minus, Play, Video } from 'radix-icons-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { titlePageContext } from '../ActionsPage/actions-page';
|
||||
import ActionsMenu from '../ActionsPage/ActionsMenu.svelte';
|
||||
import type { TitleInfoProperty } from '../HeroTitleInfo';
|
||||
import HeroTitleInfo from '../HeroTitleInfo.svelte';
|
||||
|
||||
const {
|
||||
tmdbId,
|
||||
tmdbMovie,
|
||||
inLibrary,
|
||||
progress,
|
||||
handleAddToLibrary,
|
||||
handleRemoveFromLibrary,
|
||||
isWatched,
|
||||
toggleIsWatched,
|
||||
autoplayCandidate,
|
||||
autoplayStream,
|
||||
unsubscribe
|
||||
} = movieUserDataContext.getContext();
|
||||
const { componentStack } = titlePageContext.getContext();
|
||||
const background = getBackgroundPage();
|
||||
|
||||
const { visibleStyle } = setUiVisibilityContext();
|
||||
const { registrar: registerScroll } = setScrollContext();
|
||||
|
||||
let trailerId: string | undefined;
|
||||
let titleProperties: TitleInfoProperty[] = [];
|
||||
|
||||
$: recommendations = tmdbApi.v3.movieRecommendations(Number(tmdbId)).then((r) => r.data.results);
|
||||
|
||||
$tmdbMovie.then(async (movie) => {
|
||||
trailerId = movie?.videos?.results?.find(
|
||||
(video) => video.type === 'Trailer' && video.site === 'YouTube'
|
||||
)?.key;
|
||||
|
||||
if (movie?.runtime) {
|
||||
titleProperties.push({
|
||||
label: formatMinutesToTime(movie.runtime)
|
||||
});
|
||||
}
|
||||
|
||||
if (movie?.vote_average) {
|
||||
titleProperties.push({
|
||||
label: `${movie.vote_average.toFixed(1)} TMDB (${formatThousands(movie.vote_count ?? 0)})`,
|
||||
href: `https://www.themoviedb.org/movie/${movie.id}`
|
||||
});
|
||||
}
|
||||
|
||||
if (movie?.genres) {
|
||||
titleProperties.push({
|
||||
label: movie.genres.map((g) => g.name).join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
if ($localSettings.enableTrailers && trailerId) {
|
||||
titleProperties.push({
|
||||
icon: Video,
|
||||
href: `https://www.youtube.com/watch?v=${trailerId}`
|
||||
});
|
||||
}
|
||||
|
||||
titleProperties = titleProperties;
|
||||
});
|
||||
$: if ($localSettings.autoplayTrailers && trailerId) {
|
||||
background?.playYoutubeVideo({ tmdbId, videoId: trailerId, onBackground: true });
|
||||
}
|
||||
|
||||
function openEpisodeMenu() {
|
||||
componentStack.create(ActionsMenu, {
|
||||
tmdbId
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<ComponentStackContainer>
|
||||
<div class="relative" use:registerScroll>
|
||||
<Container
|
||||
class="h-[calc(100vh-4rem)] flex flex-col py-16 px-32"
|
||||
on:enter={scrollIntoView({ top: 999 })}
|
||||
>
|
||||
<HeroCarousel>
|
||||
{#await $tmdbMovie then movie}
|
||||
{#if movie}
|
||||
<HeroTitleInfo
|
||||
title={`${movie.title} (${new Date(movie.release_date ?? 0).getFullYear()})`}
|
||||
properties={titleProperties}
|
||||
overview={movie.overview ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
<Container direction="horizontal" class="flex mt-8 space-x-4" focusOnMount>
|
||||
<Button
|
||||
action={autoplayStream}
|
||||
secondaryAction={openEpisodeMenu}
|
||||
disabled={!$autoplayCandidate.candidate}
|
||||
>
|
||||
Play
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
|
||||
{#if trailerId}
|
||||
<Button
|
||||
on:clickOrSelect={() =>
|
||||
trailerId && background?.playYoutubeVideo({ tmdbId, videoId: trailerId })}
|
||||
>
|
||||
<Video slot="icon" size={19} />
|
||||
Play Trailer
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if !$inLibrary}
|
||||
<Button action={handleAddToLibrary} icon={Bookmark}>Add to Library</Button>
|
||||
{:else}
|
||||
<Button action={handleRemoveFromLibrary} icon={Minus}>Remove from Library</Button>
|
||||
{/if}
|
||||
|
||||
<Button action={toggleIsWatched}>
|
||||
{#if $isWatched}
|
||||
Mark as Unwatched
|
||||
{:else}
|
||||
Mark as Watched
|
||||
{/if}
|
||||
<Check slot="icon" size={19} />
|
||||
</Button>
|
||||
|
||||
{#if PLATFORM_WEB}
|
||||
<Button
|
||||
on:clickOrSelect={() =>
|
||||
window.open('https://www.themoviedb.org/movie/' + tmdbId, '_blank')}
|
||||
>
|
||||
Open In TMDB
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</HeroCarousel>
|
||||
</Container>
|
||||
<div class="relative z-10" style={$visibleStyle}>
|
||||
<Container on:enter={scrollIntoView({ top: 0 })} class="">
|
||||
{#await $tmdbMovie then movie}
|
||||
<Carousel scrollClass="px-32" class="mb-16">
|
||||
<div slot="header">Show Cast</div>
|
||||
{#each movie?.credits?.cast?.slice(0, 15) || [] as credit}
|
||||
<TmdbPersonCard on:enter={scrollIntoView({ left: 128 })} tmdbCredit={credit} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/await}
|
||||
{#await recommendations then recommendations}
|
||||
<Carousel scrollClass="px-32" class="mb-16">
|
||||
<div slot="header">Recommendations</div>
|
||||
{#each recommendations || [] as recommendation}
|
||||
<TmdbCard item={recommendation} on:enter={scrollIntoView({ left: 128 })} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/await}
|
||||
</Container>
|
||||
{#await $tmdbMovie then movie}
|
||||
<Container class="flex-1 bg-secondary-950 pt-8 px-32" on:enter={scrollIntoView({ top: 0 })}>
|
||||
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1>
|
||||
<div class="text-zinc-300 font-medium text-lg flex flex-wrap">
|
||||
<div class="flex-1">
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Directed By</h2>
|
||||
<div>
|
||||
{movie?.credits.crew
|
||||
?.filter((c) => c.job === 'Director')
|
||||
?.map((c) => c.name)
|
||||
.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Written By</h2>
|
||||
<div>
|
||||
{movie?.credits.crew
|
||||
?.filter((c) => c.job === 'Writer')
|
||||
?.map((c) => c.name)
|
||||
.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Languages</h2>
|
||||
<div>
|
||||
{movie?.spoken_languages?.map((language) => language.name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Release Date</h2>
|
||||
<div>
|
||||
{new Date(movie?.release_date || 0).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</ComponentStackContainer>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Container from '$components/Container.svelte';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import classNames from 'classnames';
|
||||
import type { MediaSourceDto, StreamCandidateDto } from '../../../apis/reiverr/reiverr.openapi';
|
||||
import { capitalize } from '../../../utils';
|
||||
|
||||
export let sources: { source: MediaSourceDto; streams: Promise<StreamCandidateDto[]> }[];
|
||||
export let createStreamDetailsDialog: (
|
||||
source: MediaSourceDto,
|
||||
stream: StreamCandidateDto
|
||||
) => void;
|
||||
</script>
|
||||
|
||||
<Container
|
||||
class="flex-1 bg-secondary-950 pt-8 pb-16 px-32 flex flex-col"
|
||||
on:enter={scrollIntoView({ top: 32 })}
|
||||
>
|
||||
{#each sources as source}
|
||||
{#await source.streams}
|
||||
Loading...
|
||||
{:then streams}
|
||||
{#if streams?.length}
|
||||
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">
|
||||
{capitalize(source.source.id)}
|
||||
</h1>
|
||||
<Container
|
||||
direction="grid"
|
||||
gridCols={2}
|
||||
class={classNames('grid gap-8', {
|
||||
'grid-cols-1': (streams || []).length < 2,
|
||||
'grid-cols-2': (streams || []).length >= 2
|
||||
})}
|
||||
>
|
||||
{#each streams || [] as stream}
|
||||
<Container
|
||||
class={classNames(
|
||||
'flex space-x-8 items-center text-zinc-300 font-medium relative overflow-hidden',
|
||||
'px-8 py-4 border-2 border-transparent rounded-xl',
|
||||
{
|
||||
'bg-secondary-800 focus-within:bg-primary-700 focus-within:border-primary-500': true,
|
||||
'hover:bg-primary-700 hover:border-primary-500 cursor-pointer': true
|
||||
// 'bg-primary-700 focus-within:border-primary-500': selected,
|
||||
// 'bg-secondary-800 focus-within:border-zinc-300': !selected
|
||||
}
|
||||
)}
|
||||
on:clickOrSelect={() => createStreamDetailsDialog(source.source, stream)}
|
||||
on:enter={scrollIntoView({ vertical: 128 })}
|
||||
focusOnClick
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-lg">
|
||||
{stream.title}
|
||||
</h1>
|
||||
</div>
|
||||
{#each stream.properties.slice(0, 2) as property}
|
||||
<div>
|
||||
{property.formatted ?? property.value}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- <div>
|
||||
{file?.mediaInfo?.runTime}
|
||||
</div>
|
||||
<div>
|
||||
{formatSize(file?.size || 0)}
|
||||
</div> -->
|
||||
</Container>
|
||||
{/each}
|
||||
</Container>
|
||||
{/if}
|
||||
{/await}
|
||||
{/each}
|
||||
</Container>
|
||||
@@ -1,63 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Play, Trash } from 'radix-icons-svelte';
|
||||
import Container from '$components/Container.svelte';
|
||||
import type { VideoStreamCandidateDto } from '../../../apis/reiverr/reiverr.openapi';
|
||||
import Button from '../../../components/Button/Button.svelte';
|
||||
import Dialog from '../../../components/Dialog/Dialog.svelte';
|
||||
|
||||
export let stream: VideoStreamCandidateDto;
|
||||
|
||||
// export let file: FileResource;
|
||||
// export let title = '';
|
||||
// export let subtitle = '';
|
||||
export let backgroundUrl: string;
|
||||
export let streamMovie: () => Promise<any>;
|
||||
export let onDelete: () => void;
|
||||
|
||||
async function handleDeleteFile() {
|
||||
// return sonarrApi.deleteSonarrEpisode(file.id || -1).then(() => onDelete());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog class="flex flex-col relative">
|
||||
{#if backgroundUrl}
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center h-52"
|
||||
style="background-image: url({backgroundUrl}); -webkit-mask-image: radial-gradient(at 90% 10%, hsla(0,0%,0%,1) 0px, transparent 70%);"
|
||||
/>
|
||||
{/if}
|
||||
<div class="z-10">
|
||||
{#if backgroundUrl}
|
||||
<div class="h-24" />
|
||||
{/if}
|
||||
<h1 class="h3">{stream.title}</h1>
|
||||
<h2 class="h4 mb-4">{stream.key}</h2>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto] font-medium mb-16
|
||||
[&>*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-2
|
||||
[&>*:nth-child(odd):not(:nth-last-child(-n+2))]:border-b [&>*:nth-child(odd):not(:nth-last-child(-n+2))]:border-secondary-600
|
||||
[&>*:nth-child(even):not(:nth-last-child(-n+2))]:border-b [&>*:nth-child(even):not(:nth-last-child(-n+2))]:border-secondary-600"
|
||||
>
|
||||
{#each stream.properties as property}
|
||||
<span class="pr-8">{property.label}</span>
|
||||
<span class="truncate" title={property.formatted ?? property.value.toString()}>
|
||||
{property.formatted ?? property.value}
|
||||
</span>
|
||||
{/each}
|
||||
<!-- <span class="border-b border-secondary-600">Runtime</span>
|
||||
<span class="border-b border-secondary-600">{file.mediaInfo?.runTime}</span>
|
||||
<span class="border-b border-secondary-600">Size on Disk</span>
|
||||
<span class="border-b border-secondary-600">{formatSize(file.size || 0)}</span>
|
||||
<span>Quality</span>
|
||||
<span>{file.quality?.quality?.name}</span> -->
|
||||
</div>
|
||||
|
||||
<Container class="flex flex-col space-y-4">
|
||||
<Button type="secondary" icon={Play} action={streamMovie}>Play</Button>
|
||||
<Button type="secondary" confirmDanger action={handleDeleteFile} disabled={true}>
|
||||
<Trash size={19} slot="icon" />
|
||||
Delete File
|
||||
</Button>
|
||||
</Container>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Dialog from '../../../components/Dialog/Dialog.svelte';
|
||||
import {
|
||||
type EpisodeDownload,
|
||||
sonarrApi,
|
||||
type SonarrEpisode
|
||||
} from '../../../apis/sonarr/sonarr-api';
|
||||
import Button from '../../../components/Button/Button.svelte';
|
||||
import Container from '$components/Container.svelte';
|
||||
import { formatSize } from '../../../utils';
|
||||
import { Cross1 } from 'radix-icons-svelte';
|
||||
import { capitalize } from '../../../utils.js';
|
||||
import type { Download } from '../../../apis/combined-types';
|
||||
|
||||
export let download: Download;
|
||||
export let title: string;
|
||||
export let subtitle: string;
|
||||
export let backgroundUrl: string;
|
||||
export let onCancel: () => void;
|
||||
|
||||
function handleCancelDownload() {
|
||||
return sonarrApi.cancelDownload(download.id || -1).then(() => onCancel());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog class="flex flex-col relative">
|
||||
{#if backgroundUrl}
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center h-52"
|
||||
style="background-image: url({backgroundUrl}); -webkit-mask-image: radial-gradient(at 90% 10%, hsla(0,0%,0%,1) 0px, transparent 70%);"
|
||||
/>
|
||||
{/if}
|
||||
<div class="z-10">
|
||||
{#if backgroundUrl}
|
||||
<div class="h-24" />
|
||||
{/if}
|
||||
<h1 class="h3">{title}</h1>
|
||||
<h2 class="h4 mb-4">{subtitle}</h2>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_max-content] font-medium mb-16
|
||||
[&>*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-1"
|
||||
>
|
||||
<span class="border-b border-secondary-600">Status</span>
|
||||
<span class="border-b border-secondary-600">{capitalize(download.status || '')}</span>
|
||||
|
||||
<span class="border-b border-secondary-600">Progress</span>
|
||||
<span class="border-b border-secondary-600">
|
||||
{formatSize((download?.size || 0) - (download?.sizeleft || 1))} / {formatSize(
|
||||
download?.size || 0
|
||||
)}
|
||||
</span>
|
||||
<span class="border-b border-secondary-600">Estimated Time Left</span>
|
||||
<span class="border-b border-secondary-600">{download.timeleft}</span>
|
||||
<span class="border-b border-secondary-600">Source</span>
|
||||
<span class="border-b border-secondary-600">{download.indexer}</span>
|
||||
<span>Quality</span>
|
||||
<span>{download.quality?.quality?.name}</span>
|
||||
</div>
|
||||
|
||||
<Container class="flex flex-col space-y-4">
|
||||
<Button type="secondary" confirmDanger action={handleCancelDownload}>
|
||||
<Cross1 size={19} slot="icon" />
|
||||
Cancel Downloads
|
||||
</Button>
|
||||
</Container>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { sonarrApi } from '../../../apis/sonarr/sonarr-api';
|
||||
import Container from '$components/Container.svelte';
|
||||
import { formatSize } from '../../../utils';
|
||||
import { Trash } from 'radix-icons-svelte';
|
||||
import type { FileResource } from '../../../apis/combined-types';
|
||||
import Dialog from '../../../components/Dialog/Dialog.svelte';
|
||||
import Button from '../../../components/Button/Button.svelte';
|
||||
|
||||
export let file: FileResource;
|
||||
export let title = '';
|
||||
export let subtitle = '';
|
||||
export let backgroundUrl: string;
|
||||
export let onDelete: () => void;
|
||||
|
||||
function handleDeleteFile() {
|
||||
return sonarrApi.deleteSonarrEpisode(file.id || -1).then(() => onDelete());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog class="flex flex-col relative">
|
||||
{#if backgroundUrl}
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center h-52"
|
||||
style="background-image: url({backgroundUrl}); -webkit-mask-image: radial-gradient(at 90% 10%, hsla(0,0%,0%,1) 0px, transparent 70%);"
|
||||
/>
|
||||
{/if}
|
||||
<div class="z-10">
|
||||
{#if backgroundUrl}
|
||||
<div class="h-24" />
|
||||
{/if}
|
||||
<h1 class="h3">{title}</h1>
|
||||
<h2 class="h4 mb-4">{subtitle}</h2>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_max-content] font-medium mb-16
|
||||
[&>*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-1"
|
||||
>
|
||||
<span class="border-b border-secondary-600">Runtime</span>
|
||||
<span class="border-b border-secondary-600">{file.mediaInfo?.runTime}</span>
|
||||
<span class="border-b border-secondary-600">Size on Disk</span>
|
||||
<span class="border-b border-secondary-600">{formatSize(file.size || 0)}</span>
|
||||
<span>Quality</span>
|
||||
<span>{file.quality?.quality?.name}</span>
|
||||
</div>
|
||||
|
||||
<Container class="flex flex-col space-y-4">
|
||||
<Button type="secondary" confirmDanger action={handleDeleteFile}>
|
||||
<Trash size={19} slot="icon" />
|
||||
Delete File
|
||||
</Button>
|
||||
</Container>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -1,25 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { componentStackContext } from '$lib/components/ComponentStack/component-stack.store';
|
||||
import ComponentStack from '$lib/components/ComponentStack/ComponentStack.svelte';
|
||||
import Container from '$components/Container.svelte';
|
||||
import type { TmdbEpisode, TmdbSeries } from '$lib/apis/tmdb/tmdb-api';
|
||||
import Button from '$lib/components/Button/Button.svelte';
|
||||
import TmdbCard from '$lib/components/Card/TmdbCard.svelte';
|
||||
import Carousel from '$lib/components/Carousel/Carousel.svelte';
|
||||
import TmdbEpisodeCard from '$lib/components/EpisodeCard/TmdbEpisodeCard.svelte';
|
||||
import { createBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import { TMDB_BACKDROP_SMALLEST } from '$lib/constants';
|
||||
import HeroCarousel from '$lib/components/HeroShowcase/HeroCarousel.svelte';
|
||||
import TmdbPersonCard from '$lib/components/PersonCard/TmdbPersonCard.svelte';
|
||||
import { useComponentStack } from '$lib/components/StackRouter/stack-router.store';
|
||||
import { PLATFORM_WEB, TMDB_BACKDROP_SMALLEST } from '$lib/constants';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import { localSettings } from '$lib/stores/localstorage.store';
|
||||
import { getScrollContext, setScrollContext } from '$lib/stores/scroll.store';
|
||||
import { setUiVisibilityContext } from '$lib/stores/ui-visibility.store';
|
||||
import { seriesUserDataContext } from '$lib/stores/user-data/title-user-data.store';
|
||||
import { tmdbApi } from '$lib/stores/user.store';
|
||||
import { formatThousands } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { Bookmark, ExternalLink, Gear, Minus, Play, Video } from 'radix-icons-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { titlePageContext } from '../ActionsPage/actions-page';
|
||||
import SeriesPageDetails from './SeriesPageDetails.svelte';
|
||||
import type { TitleInfoProperty } from '../HeroTitleInfo';
|
||||
import TitleProperties from '../HeroTitleInfo.svelte';
|
||||
import TitleSheet from '../TitleSheet.svelte';
|
||||
import { useEpisodesData } from './episode-carousel';
|
||||
import StreamablesView from './StreamablesView.svelte';
|
||||
|
||||
export let id: string;
|
||||
|
||||
const { componentStack } = componentStackContext.createContext();
|
||||
// const { componentStack } = componentStackContext.createContext();
|
||||
const componentStack = useComponentStack();
|
||||
const background = createBackgroundPage({ backgroundMediaId: id, videoMediaId: id });
|
||||
const seriesUserData = seriesUserDataContext.createContext(id);
|
||||
const { tmdbSeries, unsubscribe } = seriesUserData;
|
||||
titlePageContext.createContext();
|
||||
const {
|
||||
tmdbId,
|
||||
tmdbSeries,
|
||||
inLibrary,
|
||||
handleAddToLibrary,
|
||||
handleRemoveFromLibrary,
|
||||
nextEpisode,
|
||||
episodesUserData,
|
||||
isWatched,
|
||||
toggleIsWatched,
|
||||
autoplayCandidate,
|
||||
playStream,
|
||||
autoplayStream,
|
||||
unsubscribe
|
||||
} = seriesUserDataContext.createContext(id);
|
||||
// titlePageContext.createContext();
|
||||
|
||||
componentStack.push({
|
||||
component: SeriesPageDetails,
|
||||
props: {}
|
||||
});
|
||||
// componentStack.push({
|
||||
// component: SeriesPageDetails,
|
||||
// props: {}
|
||||
// });
|
||||
|
||||
$tmdbSeries.then((series) => {
|
||||
const backgrounds =
|
||||
@@ -40,6 +72,91 @@
|
||||
]);
|
||||
});
|
||||
|
||||
// const { openEpisodeMenu, openManageSeries } = titlePageContext.getContext();
|
||||
|
||||
const {
|
||||
data: episodes,
|
||||
selectedEpisode,
|
||||
selectedTmdbEpisode,
|
||||
interactionObserver: episodeCardsObserver,
|
||||
onEpisodeMount,
|
||||
onEpisodeCardMouseEnter,
|
||||
onEpisodeCardMouseLeave
|
||||
} = useEpisodesData();
|
||||
|
||||
const { visibleStyle } = setUiVisibilityContext();
|
||||
const { registrar: scrollRegistrar } = setScrollContext();
|
||||
|
||||
// const episodeCards = useRegistrar();
|
||||
let trailerId: string | undefined;
|
||||
let titleProperties: TitleInfoProperty[] = [];
|
||||
const { topVisible } = getScrollContext();
|
||||
|
||||
let sheetProps: { episode: TmdbEpisode; series: TmdbSeries } | undefined;
|
||||
$: recommendations = tmdbApi.v3
|
||||
.tvSeriesRecommendations(Number(tmdbId))
|
||||
.then((r) => r.data.results);
|
||||
|
||||
$tmdbSeries.then((series) => {
|
||||
trailerId = series?.videos?.results?.find(
|
||||
(video) => video.type === 'Trailer' && video.site === 'YouTube'
|
||||
)?.key;
|
||||
|
||||
if (series && series.status !== 'Ended') {
|
||||
titleProperties.push({
|
||||
label: `Since ${new Date(series.first_air_date || Date.now())?.getFullYear()}`
|
||||
});
|
||||
} else if (series) {
|
||||
titleProperties.push({
|
||||
label: `Ended ${new Date(series.last_air_date || Date.now())?.getFullYear()}`
|
||||
});
|
||||
}
|
||||
|
||||
if (series?.vote_average) {
|
||||
titleProperties.push({
|
||||
label: `${series.vote_average.toFixed(1)} TMDB (${formatThousands(
|
||||
series.vote_count ?? 0
|
||||
)})`,
|
||||
href: `https://www.themoviedb.org/tv/${tmdbId}`
|
||||
});
|
||||
}
|
||||
|
||||
if (series?.genres) {
|
||||
titleProperties.push({
|
||||
label: series.genres.map((g) => g.name).join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
if ($localSettings.enableTrailers && trailerId) {
|
||||
titleProperties.push({
|
||||
icon: Video,
|
||||
href: `https://www.youtube.com/watch?v=${trailerId}`
|
||||
});
|
||||
}
|
||||
|
||||
titleProperties = titleProperties;
|
||||
});
|
||||
$: if ($localSettings.autoplayTrailers && trailerId) {
|
||||
background?.playYoutubeVideo({
|
||||
tmdbId,
|
||||
videoId: trailerId,
|
||||
onBackground: true
|
||||
});
|
||||
}
|
||||
|
||||
function openStreamableSelectorModal(opts: { tmdbId: string; season: number; episode: number }) {
|
||||
componentStack.push({
|
||||
component: StreamablesView,
|
||||
props: {
|
||||
...opts,
|
||||
openStream: async ({ id, pluginId }) => {
|
||||
// playStream();
|
||||
}
|
||||
},
|
||||
group: 'top'
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -61,4 +178,282 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ComponentStack {componentStack} />
|
||||
<div class="relative" use:scrollRegistrar>
|
||||
<HeroCarousel class="h-[calc(100vh-7.5rem)] relative" on:enter={scrollIntoView({ top: 0 })}>
|
||||
{#if $selectedTmdbEpisode}
|
||||
<div
|
||||
class={classNames(
|
||||
'flex flex-col pt-16 pb-8 px-32 transition-opacity inset-x-0 bottom-0 absolute delay-150',
|
||||
{
|
||||
'opacity-0': $topVisible
|
||||
}
|
||||
)}
|
||||
style="transform: translateZ(0);"
|
||||
>
|
||||
<TitleProperties
|
||||
title={$selectedTmdbEpisode.name ?? ''}
|
||||
properties={$selectedTmdbEpisode.properties}
|
||||
overview={$selectedTmdbEpisode.overview ?? ''}
|
||||
/>
|
||||
|
||||
<!-- {#if !PLATFORM_TV}
|
||||
<div class="flex mt-8 space-x-4">
|
||||
<AnimateScale hasFocus={false}>
|
||||
<button
|
||||
class="h-12 flex-1 flex items-center group font-medium tracking-wide bg-secondary-800 selectable rounded-xl px-6 cursor-pointer"
|
||||
on:click={() =>
|
||||
openEpisodeMenu(
|
||||
$selectedTmdbEpisode?.season_number ?? 1,
|
||||
$selectedTmdbEpisode?.episode_number ?? 1
|
||||
)}
|
||||
>
|
||||
<InfoCircled size={19} slot="icon" class="mr-2" />
|
||||
Details
|
||||
</button>
|
||||
</AnimateScale>
|
||||
</div>
|
||||
{/if} -->
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={classNames(
|
||||
'flex flex-col pt-16 pb-8 px-32 transition-opacity inset-x-0 bottom-0 absolute delay-150',
|
||||
{
|
||||
'opacity-0 pointer-events-none': !$topVisible && $selectedTmdbEpisode
|
||||
}
|
||||
)}
|
||||
style="transform: translateZ(0);"
|
||||
>
|
||||
{#await $tmdbSeries then series}
|
||||
{#if series}
|
||||
<TitleProperties
|
||||
title={series.name ?? ''}
|
||||
properties={titleProperties}
|
||||
overview={series.overview ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
<Container direction="horizontal" class="flex mt-8 space-x-4" focusOnMount>
|
||||
<Button
|
||||
action={autoplayStream}
|
||||
secondaryAction={() => {
|
||||
// openEpisodeMenu(tmdbId, $nextEpisode?.season, $nextEpisode?.episode)
|
||||
}}
|
||||
disabled={!$autoplayCandidate.candidate}
|
||||
>
|
||||
{#if $nextEpisode?.episode && $nextEpisode?.season}
|
||||
Play S{$nextEpisode?.season}E{$nextEpisode?.episode}
|
||||
{:else}
|
||||
Play
|
||||
{/if}
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
|
||||
{#if trailerId}
|
||||
<Button
|
||||
on:clickOrSelect={() =>
|
||||
trailerId && background?.playYoutubeVideo({ tmdbId, videoId: trailerId })}
|
||||
>
|
||||
<Video slot="icon" size={19} />
|
||||
Play Trailer
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if !$inLibrary}
|
||||
<Button action={handleAddToLibrary} icon={Bookmark}>Add to Library</Button>
|
||||
{:else}
|
||||
<Button action={handleRemoveFromLibrary} icon={Minus}>Remove from Library</Button>
|
||||
{/if}
|
||||
|
||||
<!-- <Button action={toggleIsWatched}>
|
||||
{#if $isWatched}
|
||||
Mark as Unwatched
|
||||
{:else}
|
||||
Mark as Watched
|
||||
{/if}
|
||||
<Check slot="icon" size={19} />
|
||||
</Button> -->
|
||||
<Button
|
||||
on:clickOrSelect={() => {
|
||||
// openManageSeries(tmdbId)
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
<Gear slot="icon" size={19} />
|
||||
</Button>
|
||||
|
||||
{#if PLATFORM_WEB}
|
||||
<Button
|
||||
on:clickOrSelect={() =>
|
||||
window.open(`https://www.themoviedb.org/tv/${tmdbId}`, '_blank')}
|
||||
>
|
||||
Open In TMDB
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</div>
|
||||
</HeroCarousel>
|
||||
<div class="relative z-10" style={$visibleStyle}>
|
||||
{#await $tmdbSeries then tmdbSeries}
|
||||
{#if $episodes.length}
|
||||
<Carousel
|
||||
scrollClass="px-32"
|
||||
on:enter={scrollIntoView({ bottom: 64 })}
|
||||
class="mb-8"
|
||||
hideControls={$topVisible}
|
||||
scrollIndexes
|
||||
focusFirstOnBack={false}
|
||||
on:scrollIndex={({ detail: i }) => {
|
||||
const episode = $episodes[i];
|
||||
|
||||
selectedEpisode.set({
|
||||
season: episode?.season_number ?? 1,
|
||||
episode: episode?.episode_number ?? 1
|
||||
});
|
||||
}}
|
||||
let:scrollToIndex
|
||||
>
|
||||
<span
|
||||
slot="header"
|
||||
class={classNames('transition-opacity', {
|
||||
// 'opacity-0': $topVisible
|
||||
})}
|
||||
>
|
||||
{#if $selectedTmdbEpisode}
|
||||
Season {$selectedTmdbEpisode.season_number}
|
||||
<!-- <div class="text-secondary-400 text-sm">
|
||||
{$selectedTmdbEpisode.season.episodes?.length} Episodes
|
||||
</div> -->
|
||||
{:else}
|
||||
Episodes
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#each $episodes as episode, i (episode.id)}
|
||||
{@const userData = $episodesUserData.find(
|
||||
(e) => e.season === episode.season_number && e.episode === episode.episode_number
|
||||
)}
|
||||
|
||||
{#key episode.id}
|
||||
<TmdbEpisodeCard
|
||||
{episode}
|
||||
series={tmdbSeries}
|
||||
on:mount={(e) =>
|
||||
onEpisodeMount(e.detail, episode.season_number ?? 1, episode.episode_number ?? 1)}
|
||||
on:enter={(e) => {
|
||||
// if (PLATFORM_TV) {
|
||||
// scrollIntoView({
|
||||
// left: 128
|
||||
// })(e);
|
||||
// } else {
|
||||
scrollToIndex(i);
|
||||
// }
|
||||
|
||||
// selectedEpisode.set({
|
||||
// season: episode.season_number ?? 1,
|
||||
// episode: episode.episode_number ?? 1
|
||||
// });
|
||||
}}
|
||||
on:mouseenter={() =>
|
||||
onEpisodeCardMouseEnter({
|
||||
episode: episode.episode_number ?? 1,
|
||||
season: episode.season_number ?? 1
|
||||
})}
|
||||
on:mouseleave={() => onEpisodeCardMouseLeave()}
|
||||
isWatched={userData?.watched || false}
|
||||
progress={userData?.progress}
|
||||
on:clickOrSelect={() =>
|
||||
openStreamableSelectorModal({
|
||||
tmdbId,
|
||||
season: episode.season_number ?? 1,
|
||||
episode: episode.episode_number ?? 1
|
||||
})}
|
||||
/>
|
||||
{/key}
|
||||
{/each}
|
||||
<div use:episodeCardsObserver />
|
||||
</Carousel>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
{#await $tmdbSeries then series}
|
||||
<Carousel scrollClass="px-32" class="mb-8" on:enter={scrollIntoView({ top: 64 + 32 })}>
|
||||
<div slot="header">
|
||||
{#if $selectedTmdbEpisode?.season.aggregate_credits}
|
||||
Season {$selectedTmdbEpisode.season_number} Cast
|
||||
{:else}
|
||||
Show Cast
|
||||
{/if}
|
||||
</div>
|
||||
{#each ($selectedTmdbEpisode?.season.aggregate_credits ?? series?.aggregate_credits)?.cast?.slice(0, 15) || [] as credit, i (credit.id)}
|
||||
<TmdbPersonCard on:enter={scrollIntoView({ left: 128 })} tmdbCredit={credit} index={i} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/await}
|
||||
{#await recommendations then recommendations}
|
||||
<Carousel scrollClass="px-32" class="mb-8" on:enter={scrollIntoView({ top: 64 + 32 })}>
|
||||
<div slot="header">Recommendations</div>
|
||||
{#each recommendations || [] as recommendation (recommendation.id)}
|
||||
<TmdbCard item={recommendation} on:enter={scrollIntoView({ left: 128 })} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/await}
|
||||
{#await $tmdbSeries then series}
|
||||
<Container
|
||||
class="flex-1 bg-secondary-950 pt-16 pb-8 px-32"
|
||||
on:enter={scrollIntoView({ bottom: 0 })}
|
||||
>
|
||||
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1>
|
||||
<div class="text-zinc-300 font-medium text-lg flex flex-wrap">
|
||||
<div class="flex-1">
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Created By</h2>
|
||||
{#each series?.created_by || [] as creator}
|
||||
<div>{creator.name}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Network</h2>
|
||||
<div>{series?.networks?.[0]?.name}</div>
|
||||
</div>
|
||||
{#if series.number_of_seasons}
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Seasons</h2>
|
||||
<div>{series.number_of_seasons}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Language</h2>
|
||||
<div>{series?.spoken_languages?.[0]?.name}</div>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Last Air Date</h2>
|
||||
<div>{series?.last_air_date}</div>
|
||||
</div>
|
||||
{#if series.number_of_episodes}
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Episodes</h2>
|
||||
<div>{series.number_of_episodes}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if sheetProps}
|
||||
<TitleSheet
|
||||
episode={sheetProps.episode}
|
||||
series={sheetProps.series}
|
||||
handleMarkAsWatched={async ({ series, episode }) => {
|
||||
// handleMarkAsWatched({ series, episode });
|
||||
}}
|
||||
handleClose={() => (sheetProps = undefined)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Container from '$components/Container.svelte';
|
||||
import type { TmdbEpisode, TmdbSeries } from '$lib/apis/tmdb/tmdb-api';
|
||||
import Button from '$lib/components/Button/Button.svelte';
|
||||
import TmdbCard from '$lib/components/Card/TmdbCard.svelte';
|
||||
import Carousel from '$lib/components/Carousel/Carousel.svelte';
|
||||
import { componentStackContext } from '$lib/components/ComponentStack/component-stack.store';
|
||||
import TmdbEpisodeCard from '$lib/components/EpisodeCard/TmdbEpisodeCard.svelte';
|
||||
import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import HeroCarousel from '$lib/components/HeroShowcase/HeroCarousel.svelte';
|
||||
import TmdbPersonCard from '$lib/components/PersonCard/TmdbPersonCard.svelte';
|
||||
import { PLATFORM_WEB } from '$lib/constants';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import { localSettings } from '$lib/stores/localstorage.store';
|
||||
import { getScrollContext, setScrollContext } from '$lib/stores/scroll.store';
|
||||
import { setUiVisibilityContext } from '$lib/stores/ui-visibility.store';
|
||||
import { seriesUserDataContext } from '$lib/stores/user-data/title-user-data.store';
|
||||
import { tmdbApi } from '$lib/stores/user.store';
|
||||
import { formatThousands } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { Bookmark, ExternalLink, Gear, Minus, Play, Video } from 'radix-icons-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { titlePageContext } from '../ActionsPage/actions-page';
|
||||
import type { TitleInfoProperty } from '../HeroTitleInfo';
|
||||
import TitleProperties from '../HeroTitleInfo.svelte';
|
||||
import TitleSheet from '../TitleSheet.svelte';
|
||||
import { useEpisodesData } from './episode-carousel';
|
||||
import StreamablesView from './StreamablesView.svelte';
|
||||
|
||||
const background = getBackgroundPage();
|
||||
|
||||
const { componentStack } = componentStackContext.getContext();
|
||||
const { openEpisodeMenu, openManageSeries } = titlePageContext.getContext();
|
||||
const {
|
||||
tmdbId,
|
||||
tmdbSeries,
|
||||
inLibrary,
|
||||
handleAddToLibrary,
|
||||
handleRemoveFromLibrary,
|
||||
nextEpisode,
|
||||
episodesUserData,
|
||||
isWatched,
|
||||
toggleIsWatched,
|
||||
autoplayCandidate,
|
||||
playStream,
|
||||
autoplayStream,
|
||||
unsubscribe
|
||||
} = seriesUserDataContext.getContext();
|
||||
const {
|
||||
data: episodes,
|
||||
selectedEpisode,
|
||||
selectedTmdbEpisode,
|
||||
interactionObserver: episodeCardsObserver,
|
||||
onEpisodeMount,
|
||||
onEpisodeCardMouseEnter,
|
||||
onEpisodeCardMouseLeave
|
||||
} = useEpisodesData();
|
||||
|
||||
const { visibleStyle } = setUiVisibilityContext();
|
||||
const { registrar: scrollRegistrar } = setScrollContext();
|
||||
|
||||
// const episodeCards = useRegistrar();
|
||||
let trailerId: string | undefined;
|
||||
let titleProperties: TitleInfoProperty[] = [];
|
||||
const { topVisible } = getScrollContext();
|
||||
|
||||
let sheetProps: { episode: TmdbEpisode; series: TmdbSeries } | undefined;
|
||||
$: recommendations = tmdbApi.v3
|
||||
.tvSeriesRecommendations(Number(tmdbId))
|
||||
.then((r) => r.data.results);
|
||||
|
||||
$tmdbSeries.then((series) => {
|
||||
trailerId = series?.videos?.results?.find(
|
||||
(video) => video.type === 'Trailer' && video.site === 'YouTube'
|
||||
)?.key;
|
||||
|
||||
if (series && series.status !== 'Ended') {
|
||||
titleProperties.push({
|
||||
label: `Since ${new Date(series.first_air_date || Date.now())?.getFullYear()}`
|
||||
});
|
||||
} else if (series) {
|
||||
titleProperties.push({
|
||||
label: `Ended ${new Date(series.last_air_date || Date.now())?.getFullYear()}`
|
||||
});
|
||||
}
|
||||
|
||||
if (series?.vote_average) {
|
||||
titleProperties.push({
|
||||
label: `${series.vote_average.toFixed(1)} TMDB (${formatThousands(
|
||||
series.vote_count ?? 0
|
||||
)})`,
|
||||
href: `https://www.themoviedb.org/tv/${tmdbId}`
|
||||
});
|
||||
}
|
||||
|
||||
if (series?.genres) {
|
||||
titleProperties.push({
|
||||
label: series.genres.map((g) => g.name).join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
if ($localSettings.enableTrailers && trailerId) {
|
||||
titleProperties.push({
|
||||
icon: Video,
|
||||
href: `https://www.youtube.com/watch?v=${trailerId}`
|
||||
});
|
||||
}
|
||||
|
||||
titleProperties = titleProperties;
|
||||
});
|
||||
$: if ($localSettings.autoplayTrailers && trailerId) {
|
||||
background?.playYoutubeVideo({
|
||||
tmdbId,
|
||||
videoId: trailerId,
|
||||
onBackground: true
|
||||
});
|
||||
}
|
||||
|
||||
function openStreamableSelectorModal(opts: { tmdbId: string; season: number; episode: number }) {
|
||||
componentStack.push({
|
||||
component: StreamablesView,
|
||||
props: {
|
||||
...opts,
|
||||
openStream: async ({ id, pluginId }) => {
|
||||
// playStream();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative" use:scrollRegistrar>
|
||||
<HeroCarousel class="h-[calc(100vh-7.5rem)] relative" on:enter={scrollIntoView({ top: 0 })}>
|
||||
{#if $selectedTmdbEpisode}
|
||||
<div
|
||||
class={classNames(
|
||||
'flex flex-col pt-16 pb-8 px-32 transition-opacity inset-x-0 bottom-0 absolute delay-150',
|
||||
{
|
||||
'opacity-0': $topVisible
|
||||
}
|
||||
)}
|
||||
style="transform: translateZ(0);"
|
||||
>
|
||||
<TitleProperties
|
||||
title={$selectedTmdbEpisode.name ?? ''}
|
||||
properties={$selectedTmdbEpisode.properties}
|
||||
overview={$selectedTmdbEpisode.overview ?? ''}
|
||||
/>
|
||||
|
||||
<!-- {#if !PLATFORM_TV}
|
||||
<div class="flex mt-8 space-x-4">
|
||||
<AnimateScale hasFocus={false}>
|
||||
<button
|
||||
class="h-12 flex-1 flex items-center group font-medium tracking-wide bg-secondary-800 selectable rounded-xl px-6 cursor-pointer"
|
||||
on:click={() =>
|
||||
openEpisodeMenu(
|
||||
$selectedTmdbEpisode?.season_number ?? 1,
|
||||
$selectedTmdbEpisode?.episode_number ?? 1
|
||||
)}
|
||||
>
|
||||
<InfoCircled size={19} slot="icon" class="mr-2" />
|
||||
Details
|
||||
</button>
|
||||
</AnimateScale>
|
||||
</div>
|
||||
{/if} -->
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={classNames(
|
||||
'flex flex-col pt-16 pb-8 px-32 transition-opacity inset-x-0 bottom-0 absolute delay-150',
|
||||
{
|
||||
'opacity-0 pointer-events-none': !$topVisible && $selectedTmdbEpisode
|
||||
}
|
||||
)}
|
||||
style="transform: translateZ(0);"
|
||||
>
|
||||
{#await $tmdbSeries then series}
|
||||
{#if series}
|
||||
<TitleProperties
|
||||
title={series.name ?? ''}
|
||||
properties={titleProperties}
|
||||
overview={series.overview ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
<Container direction="horizontal" class="flex mt-8 space-x-4" focusOnMount>
|
||||
<Button
|
||||
action={autoplayStream}
|
||||
secondaryAction={() =>
|
||||
openEpisodeMenu(tmdbId, $nextEpisode?.season, $nextEpisode?.episode)}
|
||||
disabled={!$autoplayCandidate.candidate}
|
||||
>
|
||||
{#if $nextEpisode?.episode && $nextEpisode?.season}
|
||||
Play S{$nextEpisode?.season}E{$nextEpisode?.episode}
|
||||
{:else}
|
||||
Play
|
||||
{/if}
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
|
||||
{#if trailerId}
|
||||
<Button
|
||||
on:clickOrSelect={() =>
|
||||
trailerId && background?.playYoutubeVideo({ tmdbId, videoId: trailerId })}
|
||||
>
|
||||
<Video slot="icon" size={19} />
|
||||
Play Trailer
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if !$inLibrary}
|
||||
<Button action={handleAddToLibrary} icon={Bookmark}>Add to Library</Button>
|
||||
{:else}
|
||||
<Button action={handleRemoveFromLibrary} icon={Minus}>Remove from Library</Button>
|
||||
{/if}
|
||||
|
||||
<!-- <Button action={toggleIsWatched}>
|
||||
{#if $isWatched}
|
||||
Mark as Unwatched
|
||||
{:else}
|
||||
Mark as Watched
|
||||
{/if}
|
||||
<Check slot="icon" size={19} />
|
||||
</Button> -->
|
||||
<Button on:clickOrSelect={() => openManageSeries(tmdbId)}>
|
||||
Manage
|
||||
<Gear slot="icon" size={19} />
|
||||
</Button>
|
||||
|
||||
{#if PLATFORM_WEB}
|
||||
<Button
|
||||
on:clickOrSelect={() =>
|
||||
window.open(`https://www.themoviedb.org/tv/${tmdbId}`, '_blank')}
|
||||
>
|
||||
Open In TMDB
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</div>
|
||||
</HeroCarousel>
|
||||
<div class="relative z-10" style={$visibleStyle}>
|
||||
{#await $tmdbSeries then tmdbSeries}
|
||||
{#if $episodes.length}
|
||||
<Carousel
|
||||
scrollClass="px-32"
|
||||
on:enter={scrollIntoView({ bottom: 64 })}
|
||||
class="mb-8"
|
||||
hideControls={$topVisible}
|
||||
scrollIndexes
|
||||
focusFirstOnBack={false}
|
||||
on:scrollIndex={({ detail: i }) => {
|
||||
const episode = $episodes[i];
|
||||
|
||||
selectedEpisode.set({
|
||||
season: episode?.season_number ?? 1,
|
||||
episode: episode?.episode_number ?? 1
|
||||
});
|
||||
}}
|
||||
let:scrollToIndex
|
||||
>
|
||||
<span
|
||||
slot="header"
|
||||
class={classNames('transition-opacity', {
|
||||
// 'opacity-0': $topVisible
|
||||
})}
|
||||
>
|
||||
{#if $selectedTmdbEpisode}
|
||||
Season {$selectedTmdbEpisode.season_number}
|
||||
<!-- <div class="text-secondary-400 text-sm">
|
||||
{$selectedTmdbEpisode.season.episodes?.length} Episodes
|
||||
</div> -->
|
||||
{:else}
|
||||
Episodes
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#each $episodes as episode, i (episode.id)}
|
||||
{@const userData = $episodesUserData.find(
|
||||
(e) => e.season === episode.season_number && e.episode === episode.episode_number
|
||||
)}
|
||||
|
||||
{#key episode.id}
|
||||
<TmdbEpisodeCard
|
||||
{episode}
|
||||
series={tmdbSeries}
|
||||
on:mount={(e) =>
|
||||
onEpisodeMount(e.detail, episode.season_number ?? 1, episode.episode_number ?? 1)}
|
||||
on:enter={(e) => {
|
||||
// if (PLATFORM_TV) {
|
||||
// scrollIntoView({
|
||||
// left: 128
|
||||
// })(e);
|
||||
// } else {
|
||||
scrollToIndex(i);
|
||||
// }
|
||||
|
||||
// selectedEpisode.set({
|
||||
// season: episode.season_number ?? 1,
|
||||
// episode: episode.episode_number ?? 1
|
||||
// });
|
||||
}}
|
||||
on:mouseenter={() =>
|
||||
onEpisodeCardMouseEnter({
|
||||
episode: episode.episode_number ?? 1,
|
||||
season: episode.season_number ?? 1
|
||||
})}
|
||||
on:mouseleave={() => onEpisodeCardMouseLeave()}
|
||||
isWatched={userData?.watched || false}
|
||||
progress={userData?.progress}
|
||||
on:clickOrSelect={() =>
|
||||
openStreamableSelectorModal({
|
||||
tmdbId,
|
||||
season: episode.season_number ?? 1,
|
||||
episode: episode.episode_number ?? 1
|
||||
})}
|
||||
/>
|
||||
{/key}
|
||||
{/each}
|
||||
<div use:episodeCardsObserver />
|
||||
</Carousel>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
{#await $tmdbSeries then series}
|
||||
<Carousel scrollClass="px-32" class="mb-8" on:enter={scrollIntoView({ top: 64 + 32 })}>
|
||||
<div slot="header">
|
||||
{#if $selectedTmdbEpisode?.season.aggregate_credits}
|
||||
Season {$selectedTmdbEpisode.season_number} Cast
|
||||
{:else}
|
||||
Show Cast
|
||||
{/if}
|
||||
</div>
|
||||
{#each ($selectedTmdbEpisode?.season.aggregate_credits ?? series?.aggregate_credits)?.cast?.slice(0, 15) || [] as credit, i (credit.id)}
|
||||
<TmdbPersonCard on:enter={scrollIntoView({ left: 128 })} tmdbCredit={credit} index={i} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/await}
|
||||
{#await recommendations then recommendations}
|
||||
<Carousel scrollClass="px-32" class="mb-8" on:enter={scrollIntoView({ top: 64 + 32 })}>
|
||||
<div slot="header">Recommendations</div>
|
||||
{#each recommendations || [] as recommendation (recommendation.id)}
|
||||
<TmdbCard item={recommendation} on:enter={scrollIntoView({ left: 128 })} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/await}
|
||||
{#await $tmdbSeries then series}
|
||||
<Container
|
||||
class="flex-1 bg-secondary-950 pt-16 pb-8 px-32"
|
||||
on:enter={scrollIntoView({ bottom: 0 })}
|
||||
>
|
||||
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1>
|
||||
<div class="text-zinc-300 font-medium text-lg flex flex-wrap">
|
||||
<div class="flex-1">
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Created By</h2>
|
||||
{#each series?.created_by || [] as creator}
|
||||
<div>{creator.name}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Network</h2>
|
||||
<div>{series?.networks?.[0]?.name}</div>
|
||||
</div>
|
||||
{#if series.number_of_seasons}
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Seasons</h2>
|
||||
<div>{series.number_of_seasons}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Language</h2>
|
||||
<div>{series?.spoken_languages?.[0]?.name}</div>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Last Air Date</h2>
|
||||
<div>{series?.last_air_date}</div>
|
||||
</div>
|
||||
{#if series.number_of_episodes}
|
||||
<div class="mb-8">
|
||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Episodes</h2>
|
||||
<div>{series.number_of_episodes}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if sheetProps}
|
||||
<TitleSheet
|
||||
episode={sheetProps.episode}
|
||||
series={sheetProps.series}
|
||||
handleMarkAsWatched={async ({ series, episode }) => {
|
||||
// handleMarkAsWatched({ series, episode });
|
||||
}}
|
||||
handleClose={() => (sheetProps = undefined)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,21 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { StreamableDto } from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import ComponentStackContainer from '$lib/components/ComponentStack/ComponentStackContainer.svelte';
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import Marquee from '$lib/components/Marquee.svelte';
|
||||
import { useComponentStack } from '$lib/components/StackRouter/stack-router.store';
|
||||
import { TMDB_BACKDROP_SMALLEST } from '$lib/constants';
|
||||
import { scrollElementIntoView } from '$lib/scroll-into-view';
|
||||
import { reiverrApi } from '$lib/stores/user.store';
|
||||
import { capitalize } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { TriangleRight } from 'radix-icons-svelte';
|
||||
import {
|
||||
breadcrumbsContext,
|
||||
playableDataContext,
|
||||
titlePageContext
|
||||
} from '../ActionsPage/actions-page';
|
||||
import { componentStackContext } from '$lib/components/ComponentStack/component-stack.store';
|
||||
import { breadcrumbsContext, playableDataContext } from '../ActionsPage/actions-page';
|
||||
|
||||
export let tmdbId: string;
|
||||
export let season: number | undefined = undefined;
|
||||
@@ -26,7 +21,7 @@
|
||||
playableDataContext.createContext({ tmdbId, season, episode });
|
||||
const background = getBackgroundPage();
|
||||
// const { componentStack } = titlePageContext.getContext();
|
||||
const { componentStack } = componentStackContext.getContext();
|
||||
const componentStack = useComponentStack();
|
||||
|
||||
if (name) breadcrumbsContext.createContext(name);
|
||||
|
||||
@@ -49,121 +44,119 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ComponentStackContainer trapFocus sidebar={false}>
|
||||
<Container
|
||||
class={classNames('pt-16 flex flex-col min-h-screen bg-primary-900/50', 'h-screen')}
|
||||
on:back={({ detail }) => {
|
||||
componentStack.pop();
|
||||
detail.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{#if selectedRow}
|
||||
<div
|
||||
class="space-y-4 rounded-2xl h-52 flex-shrink-0 flex flex-col justify-between bg-primary-200/10 p-8 mx-24"
|
||||
>
|
||||
<div>
|
||||
{#key selectedRow.label}
|
||||
<Marquee class="text-3xl font-semibold text-primary-100">
|
||||
{capitalize(selectedRow.label)}
|
||||
</Marquee>
|
||||
{/key}
|
||||
<Container
|
||||
class={classNames('pt-16 flex flex-col min-h-screen bg-primary-900/50', 'h-screen')}
|
||||
on:back={({ detail }) => {
|
||||
componentStack.close();
|
||||
detail.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{#if selectedRow}
|
||||
<div
|
||||
class="space-y-4 rounded-2xl h-52 flex-shrink-0 flex flex-col justify-between bg-primary-200/10 p-8 mx-24"
|
||||
>
|
||||
<div>
|
||||
{#key selectedRow.label}
|
||||
<Marquee class="text-3xl font-semibold text-primary-100">
|
||||
{capitalize(selectedRow.label)}
|
||||
</Marquee>
|
||||
{/key}
|
||||
|
||||
<!-- <span class="text-secondary-200 line-clamp-2">
|
||||
<!-- <span class="text-secondary-200 line-clamp-2">
|
||||
{selectedRow.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')}
|
||||
</span> -->
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
<!-- {#each selectedRow.actions as action, index} -->
|
||||
{#each actions as action, index}
|
||||
{@const selected = index === selectedActionIndex}
|
||||
<div
|
||||
class={classNames(
|
||||
'inline-flex items-center font-medium tracking-wide h-12 ',
|
||||
'group rounded-xl px-6 bg-primary-900',
|
||||
'border-2 p-1 hover:border-primary-500',
|
||||
{
|
||||
'text-primary-100 border-transparent': !selected,
|
||||
'text-primary-100 border-primary-400': selected,
|
||||
'cursor-pointer': !(action.type === 'action' && action.disabled)
|
||||
}
|
||||
)}
|
||||
>
|
||||
{#if action.type === 'action' && action.action === 'stream'}
|
||||
<TriangleRight size={28} class="-ml-2 mr-1" />
|
||||
{/if}
|
||||
|
||||
{action.label}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="overflow-y-auto overflow-x-hidden scrollbar-hide pb-16 mx-32"
|
||||
style="backface-visibility: hidden"
|
||||
>
|
||||
{#await groups}
|
||||
Loading...
|
||||
{:then view}
|
||||
{#each view.data.items as group}
|
||||
{#each group.streamables as row, index}
|
||||
<Container
|
||||
on:enter={({ detail }) => {
|
||||
selectedRow = row;
|
||||
selectedActionIndex = 0;
|
||||
<div class="flex space-x-4">
|
||||
<!-- {#each selectedRow.actions as action, index} -->
|
||||
{#each actions as action, index}
|
||||
{@const selected = index === selectedActionIndex}
|
||||
<div
|
||||
class={classNames(
|
||||
'inline-flex items-center font-medium tracking-wide h-12 ',
|
||||
'group rounded-xl px-6 bg-primary-900',
|
||||
'border-2 p-1 hover:border-primary-500',
|
||||
{
|
||||
'text-primary-100 border-transparent': !selected,
|
||||
'text-primary-100 border-primary-400': selected,
|
||||
'cursor-pointer': !(action.type === 'action' && action.disabled)
|
||||
}
|
||||
)}
|
||||
>
|
||||
{#if action.type === 'action' && action.action === 'stream'}
|
||||
<TriangleRight size={28} class="-ml-2 mr-1" />
|
||||
{/if}
|
||||
|
||||
const el =
|
||||
detail.selectable.getSibling(-1)?.getHtmlElement() ??
|
||||
detail.selectable?.getHtmlElement();
|
||||
{action.label}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="overflow-y-auto overflow-x-hidden scrollbar-hide pb-16 mx-32"
|
||||
style="backface-visibility: hidden"
|
||||
>
|
||||
{#await groups}
|
||||
Loading...
|
||||
{:then view}
|
||||
{#each view.data.items as group}
|
||||
{#each group.streamables as row, index}
|
||||
<Container
|
||||
on:enter={({ detail }) => {
|
||||
selectedRow = row;
|
||||
selectedActionIndex = 0;
|
||||
|
||||
if (el) scrollElementIntoView(el, { top: 32 });
|
||||
}}
|
||||
on:select={() => {}}
|
||||
on:click={() => {
|
||||
selectedRow = row;
|
||||
const el =
|
||||
detail.selectable.getSibling(-1)?.getHtmlElement() ??
|
||||
detail.selectable?.getHtmlElement();
|
||||
|
||||
if (el) scrollElementIntoView(el, { top: 32 });
|
||||
}}
|
||||
on:select={() => {}}
|
||||
on:click={() => {
|
||||
selectedRow = row;
|
||||
selectedActionIndex = 0;
|
||||
}}
|
||||
on:navigate={({ detail }) => {
|
||||
if (detail.direction === 'left') {
|
||||
selectedActionIndex = Math.max(0, selectedActionIndex - 1);
|
||||
} else if (detail.direction === 'right') {
|
||||
selectedActionIndex = 0;
|
||||
}}
|
||||
on:navigate={({ detail }) => {
|
||||
if (detail.direction === 'left') {
|
||||
selectedActionIndex = Math.max(0, selectedActionIndex - 1);
|
||||
} else if (detail.direction === 'right') {
|
||||
selectedActionIndex = 0;
|
||||
// selectedActionIndex = Math.min(row.actions.length - 1, selectedActionIndex + 1);
|
||||
}
|
||||
}}
|
||||
focusOnClick
|
||||
let:hasFocus
|
||||
>
|
||||
<div class={classNames('cursor-pointer my-8 rounded-xl', {})}>
|
||||
<span
|
||||
class={classNames(
|
||||
'text-3xl font-semibold flex items-center',
|
||||
// 'text-secondary-200',
|
||||
{
|
||||
'text-secondary-500': !hasFocus,
|
||||
'text-secondary-100': hasFocus
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span class="line-clamp-1">
|
||||
{capitalize(row.label)}
|
||||
</span>
|
||||
<!-- {#if hasFocus}
|
||||
// selectedActionIndex = Math.min(row.actions.length - 1, selectedActionIndex + 1);
|
||||
}
|
||||
}}
|
||||
focusOnClick
|
||||
let:hasFocus
|
||||
>
|
||||
<div class={classNames('cursor-pointer my-8 rounded-xl', {})}>
|
||||
<span
|
||||
class={classNames(
|
||||
'text-3xl font-semibold flex items-center',
|
||||
// 'text-secondary-200',
|
||||
{
|
||||
'text-secondary-500': !hasFocus,
|
||||
'text-secondary-100': hasFocus
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span class="line-clamp-1">
|
||||
{capitalize(row.label)}
|
||||
</span>
|
||||
<!-- {#if hasFocus}
|
||||
<Play class="w-8 h-8 ml-4" />
|
||||
{/if} -->
|
||||
</span>
|
||||
<!-- <span class="text-secondary-200">
|
||||
</span>
|
||||
<!-- <span class="text-secondary-200">
|
||||
{row.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')}
|
||||
</span> -->
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- <div class="h-[1px] w-full my-2 bg-secondary-400" /> -->
|
||||
{:else}
|
||||
<div class="h-ghost m-auto">No streams available</div>
|
||||
{/each}
|
||||
<!-- <div class="h-[1px] w-full my-2 bg-secondary-400" /> -->
|
||||
{:else}
|
||||
<div class="h-ghost m-auto">No streams available</div>
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
</Container>
|
||||
</ComponentStackContainer>
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Container from '$components/Container.svelte';
|
||||
import { scrollIntoView } from '$lib/selectable';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import classNames from 'classnames';
|
||||
import { ChevronRight } from 'radix-icons-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import type { MediaSourceDto, StreamCandidateDto } from '../../apis/reiverr/reiverr.openapi';
|
||||
import Modal from '../../components/Modal/Modal.svelte';
|
||||
import { capitalize } from '../../utils';
|
||||
|
||||
export let modalId: symbol;
|
||||
export let getStreams: (source: MediaSourceDto) => Promise<StreamCandidateDto[]>;
|
||||
export let selectStream: (source: MediaSourceDto, stream: StreamCandidateDto) => void;
|
||||
|
||||
let selectedSource: MediaSourceDto | undefined = undefined;
|
||||
let streams: Record<string, Promise<StreamCandidateDto[]>> = {};
|
||||
|
||||
function selectSource(source: MediaSourceDto) {
|
||||
selectedSource = source;
|
||||
}
|
||||
|
||||
const updateStreams = (source: MediaSourceDto) => {
|
||||
streams[source.id] = getStreams(source);
|
||||
streams = streams;
|
||||
};
|
||||
$: {
|
||||
if (selectedSource && !streams[selectedSource.id]) {
|
||||
updateStreams(selectedSource);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (selectedSource !== undefined) {
|
||||
selectedSource = undefined;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal {modalId} let:close>
|
||||
<Container
|
||||
class="h-screen py-16 px-32 bg-primary-800 space-y-8 overflow-y-auto flex flex-col"
|
||||
on:back={(e) => {
|
||||
if (selectedSource !== undefined) {
|
||||
selectedSource = undefined;
|
||||
e.detail.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if !selectedSource}
|
||||
{#each $user?.mediaSources ?? [] as source}
|
||||
<Container
|
||||
on:clickOrSelect={() => selectSource(source)}
|
||||
let:hasFocus
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<span
|
||||
class={classNames('text-3xl font-semibold flex items-center', {
|
||||
'text-secondary-400': !hasFocus,
|
||||
'text-primary-100': hasFocus
|
||||
})}
|
||||
>
|
||||
{capitalize(source.name)}
|
||||
{#if hasFocus}
|
||||
<ChevronRight class="w-8 h-8 ml-4" />
|
||||
{/if}
|
||||
</span>
|
||||
</Container>
|
||||
{:else}
|
||||
<div class="h-ghost m-auto">No sources available</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{@const s = selectedSource}
|
||||
{#await streams[selectedSource.id]}
|
||||
<h1 class="h-ghost m-auto">Loading...</h1>
|
||||
{:then streams}
|
||||
{#each streams ?? [] as stream}
|
||||
<Container
|
||||
on:clickOrSelect={() => {
|
||||
close();
|
||||
tick().then(() => selectStream(s, stream));
|
||||
}}
|
||||
on:enter={scrollIntoView({ vertical: 64 })}
|
||||
let:hasFocus
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<span
|
||||
class={classNames('text-3xl font-semibold flex items-center', {
|
||||
'text-secondary-400': !hasFocus,
|
||||
'text-primary-100': hasFocus
|
||||
})}
|
||||
>
|
||||
{capitalize(stream.title)}
|
||||
<!-- {#if hasFocus}
|
||||
<Play class="w-8 h-8 ml-4" />
|
||||
{/if} -->
|
||||
</span>
|
||||
<span class="text-secondary-400">
|
||||
{stream.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')}
|
||||
</span>
|
||||
</Container>
|
||||
{:else}
|
||||
<div class="h-ghost m-auto">No streams available</div>
|
||||
{/each}
|
||||
{/await}
|
||||
{/if}
|
||||
</Container>
|
||||
</Modal>
|
||||
@@ -1,30 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { TmdbEpisode, TmdbSeries } from '$lib/apis/tmdb/tmdb-api';
|
||||
import Button from '$lib/components/Button/Button.svelte';
|
||||
import ButtonSpinner from '$lib/components/Button/ButtonSpinner.svelte';
|
||||
import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import LazyImg from '$lib/components/LazyImg.svelte';
|
||||
import { Sheet } from '$lib/components/Sheet';
|
||||
import VideoPlayer from '$lib/components/VideoPlayer/VideoPlayer.svelte';
|
||||
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
|
||||
import { componentStackContext } from '$lib/stores/component-stack.store';
|
||||
import { useIsWatched } from '$lib/stores/user-data/is-watched.store';
|
||||
import {
|
||||
type EpisodeUserData,
|
||||
TITLE_USER_DATA_CONTEXT,
|
||||
type EpisodeUserData,
|
||||
type TitleUserData
|
||||
} from '$lib/stores/user-data/title-user-data.store';
|
||||
import { reiverrApi } from '$lib/stores/user.store';
|
||||
import { timeout } from '$lib/utils';
|
||||
import { Check, Cross1, Gear, Play } from 'radix-icons-svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import StreamListMenu from './ActionsPage/StreamListMenu.svelte';
|
||||
import ButtonSpinner from '$lib/components/Button/ButtonSpinner.svelte';
|
||||
import { timeout } from '$lib/utils';
|
||||
import { playableDataContext } from './ActionsPage/actions-page';
|
||||
import {
|
||||
getBackgroundPage,
|
||||
type BackgroundPage,
|
||||
type BackgroundPageStore
|
||||
} from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import VideoPlayer from '$lib/components/VideoPlayer/VideoPlayer.svelte';
|
||||
|
||||
export let series: TmdbSeries;
|
||||
export let episode: TmdbEpisode;
|
||||
@@ -36,7 +29,6 @@
|
||||
episode: TmdbEpisode;
|
||||
}) => Promise<void>;
|
||||
|
||||
const componentStack = componentStackContext.getContext();
|
||||
const background = getBackgroundPage();
|
||||
|
||||
// export let imgUrl: string;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { derived, get, readable, type Readable, type Writable, writable } from 'svelte/store';
|
||||
import { derived, get, type Readable, type Writable, writable } from 'svelte/store';
|
||||
import { type Offsets, scrollElementIntoView } from './scroll-into-view';
|
||||
import { nestedDerived } from './utils';
|
||||
|
||||
export type BackEvent = CustomEvent<KeyEvent>;
|
||||
|
||||
@@ -977,7 +976,7 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||
|
||||
export const useRegistrar = (): { registrar: Registrar } & Readable<Selectable | undefined> => {
|
||||
const selectable = writable<Selectable | undefined>();
|
||||
const hasFocus = nestedDerived(selectable, (s) => s?.hasFocusWithin ?? readable(false));
|
||||
// const hasFocus = nestedDerived(selectable, (s) => s?.hasFocusWithin ?? readable(false));
|
||||
|
||||
const registrar: Registrar = (e) => {
|
||||
selectable.update((prev) => {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { createStoreContext } from '$lib/utils';
|
||||
import { type ComponentProps, type ComponentType, type SvelteComponentTyped } from 'svelte';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
|
||||
export type ComponentPage<T extends SvelteComponentTyped = SvelteComponentTyped> = {
|
||||
id: symbol;
|
||||
group: symbol;
|
||||
component: ComponentType<T>;
|
||||
props: ComponentProps<T>;
|
||||
preventScroll: boolean;
|
||||
};
|
||||
|
||||
export type ComponentStackStore = ReturnType<typeof useComponentStack>;
|
||||
|
||||
export function useComponentStack<P extends Record<string, unknown>>(initial?: {
|
||||
component: ComponentType<SvelteComponentTyped<P>>;
|
||||
props: P;
|
||||
group?: symbol | undefined;
|
||||
}) {
|
||||
const items = writable<ComponentPage<SvelteComponentTyped>[]>([]);
|
||||
const top = derived(items, ($items) => $items[$items.length - 1]);
|
||||
|
||||
if (initial) {
|
||||
push({
|
||||
component: initial.component,
|
||||
props: initial.props,
|
||||
group: initial.group
|
||||
});
|
||||
}
|
||||
|
||||
function close(symbol: symbol) {
|
||||
items.update((prev) => prev.filter((i) => i.id !== symbol));
|
||||
}
|
||||
|
||||
function closeGroup(group: symbol) {
|
||||
items.update((prev) => prev.filter((i) => i.group !== group));
|
||||
}
|
||||
|
||||
function push<P extends Record<string, unknown>>(opts: {
|
||||
component: ComponentType<SvelteComponentTyped<P>>;
|
||||
props: P;
|
||||
group?: symbol;
|
||||
preventScroll?: boolean;
|
||||
}) {
|
||||
const { component, props, group, preventScroll } = opts;
|
||||
|
||||
const id = Symbol();
|
||||
const item = {
|
||||
id,
|
||||
component,
|
||||
props,
|
||||
group: group || id,
|
||||
preventScroll: preventScroll || false
|
||||
};
|
||||
items.update((prev) => [...prev, item]);
|
||||
return id;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
items.set([]);
|
||||
}
|
||||
|
||||
function closeTopmost() {
|
||||
const t = get(top);
|
||||
if (t) {
|
||||
close(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: items.subscribe,
|
||||
top: {
|
||||
subscribe: top.subscribe
|
||||
},
|
||||
push,
|
||||
close,
|
||||
closeGroup,
|
||||
closeTopmost,
|
||||
pop: closeTopmost,
|
||||
reset
|
||||
};
|
||||
}
|
||||
|
||||
export const componentStackContext = createStoreContext('component-stack', useComponentStack, {
|
||||
required: true
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { TmdbSeriesFull } from '$lib/apis/tmdb/tmdb-api';
|
||||
import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
|
||||
import { createModal } from '$lib/components/Modal/modal.store';
|
||||
import { createErrorNotification } from '$lib/components/Notifications/notification.store';
|
||||
import TmdbVideoPlayer from '$lib/components/VideoPlayer/TmdbVideoPlayer.svelte';
|
||||
import StreamSelectorModal from '$lib/pages/TitlePages/StreamSelectorModal.svelte';
|
||||
import { createStoreContext } from '$lib/utils';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import type {
|
||||
@@ -664,23 +662,22 @@ export function useEpisodeUserData(tmdbId: string, season: number, episode: numb
|
||||
background?.focus();
|
||||
},
|
||||
handleOpenStreamSelector: async () => {
|
||||
createModal(StreamSelectorModal, {
|
||||
getStreams: (s) => getStreams(s, tmdbId, season, episode),
|
||||
selectStream: async (source, stream) => {
|
||||
background?.setVideo({
|
||||
id: Symbol(),
|
||||
component: TmdbVideoPlayer,
|
||||
props: {
|
||||
...(await getVideoProps()),
|
||||
streamId: stream.streamId,
|
||||
source
|
||||
},
|
||||
mediaId: tmdbId
|
||||
});
|
||||
|
||||
background?.focus();
|
||||
}
|
||||
});
|
||||
// createModal(StreamSelectorModal, {
|
||||
// getStreams: (s) => getStreams(s, tmdbId, season, episode),
|
||||
// selectStream: async (source, stream) => {
|
||||
// background?.setVideo({
|
||||
// id: Symbol(),
|
||||
// component: TmdbVideoPlayer,
|
||||
// props: {
|
||||
// ...(await getVideoProps()),
|
||||
// streamId: stream.streamId,
|
||||
// source
|
||||
// },
|
||||
// mediaId: tmdbId
|
||||
// });
|
||||
// background?.focus();
|
||||
// }
|
||||
// });
|
||||
},
|
||||
unsubscribe: () => {
|
||||
userData.unsubscribe();
|
||||
|
||||
Reference in New Issue
Block a user