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:
Aleksi Lassila
2026-02-09 00:37:22 +02:00
parent 5e50de3fef
commit 0e08aa2d6f
55 changed files with 803 additions and 4356 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
<div>select season</div>

View File

@@ -1,8 +0,0 @@
<div {...$$restProps}>
<div class="h1">
<slot name="title" />
</div>
<div class="h4">
<slot name="subtitle" />
</div>
</div>

View File

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

View File

@@ -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}-->
<!-- &lt;!&ndash; <MMAddToSonarr />&ndash;&gt;-->
<!-- {: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>-->

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
export type ViewItem = {
label: string;
handleClick: () => void;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -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
});

View File

@@ -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();