feat: Dynamic card sizes everywhere, reworked content requesting for EpisodePage

This commit is contained in:
Aleksi Lassila
2024-05-20 14:17:22 +03:00
parent 2d652ae9ba
commit 18dbc04710
13 changed files with 421 additions and 166 deletions

View File

@@ -256,7 +256,9 @@ export class SonarrApi implements ApiAsync<paths> {
params: {
query: {
includeEpisode: true,
includeSeries: true
includeSeries: true,
// @ts-ignore
pageSize: 1000
}
}
})

View File

@@ -5,25 +5,33 @@
import AnimatedSelection from './AnimateScale.svelte';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{ clickOrSelect: null }>();
export let disabled: boolean = false;
export let focusOnMount: boolean = false;
export let type: 'primary' | 'secondary' | 'primary-dark' = 'primary';
export let confirmDanger = false;
export let action: (() => Promise<any>) | null = null;
let actionIsFetching = false;
$: _disabled = disabled || actionIsFetching;
let armed = false;
let hasFocus: Readable<boolean>;
const dispatch = createEventDispatcher<{ clickOrSelect: null }>();
$: if (!$hasFocus && armed) armed = false;
function handleClickOrSelect() {
if (confirmDanger && !armed) {
armed = true;
return;
}
if (action) {
actionIsFetching = true;
action().then(() => (actionIsFetching = false));
}
dispatch('clickOrSelect');
armed = false;
}
</script>
@@ -38,6 +46,7 @@
'selectable px-6': type === 'primary' || type === 'primary-dark',
'border-2 p-1 hover:border-primary-500': type === 'secondary',
'border-primary-500': type === 'secondary' && $hasFocus,
'!border-red-500': confirmDanger && armed,
'cursor-pointer': !_disabled,
'cursor-not-allowed pointer-events-none opacity-40': _disabled
},
@@ -55,7 +64,8 @@
'border-2 border-transparent h-full w-full rounded-md flex items-center px-6':
type === 'secondary',
'bg-primary-500 text-secondary-950': type === 'secondary' && $hasFocus,
'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary'
'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary',
'!bg-red-500': confirmDanger && armed
})}
>
<div class="flex-1 text-center text-nowrap flex items-center justify-center">

View File

@@ -3,10 +3,8 @@
import PlayButton from '../PlayButton.svelte';
import ProgressBar from '../ProgressBar.svelte';
import LazyImg from '../LazyImg.svelte';
import { Star } from 'radix-icons-svelte';
import type { TitleType } from '../../types';
import Container from '../../../Container.svelte';
import { useNavigate } from 'svelte-navigator';
import type { Readable } from 'svelte/store';
import AnimatedSelection from '../AnimateScale.svelte';
import { navigate } from '../StackRouter/StackRouter';
@@ -28,8 +26,30 @@
export let orientation: 'portrait' | 'landscape' = 'landscape';
let hasFocus: Readable<boolean>;
let dimensions = getDimensions(window.innerWidth);
function getDimensions(viewportWidth: number) {
const minWidth = 240;
const margin = 128;
const gap = 32;
const cols = Math.floor((gap - 2 * margin + viewportWidth) / (minWidth + gap));
const scale = -(gap * (cols - 1) + 2 * margin - viewportWidth) / (cols * minWidth);
const newWidth = minWidth * scale;
const newHeight = (3 / 2) * newWidth;
return {
width: newWidth,
height: newHeight
};
}
</script>
<svelte:window on:resize={(e) => (dimensions = getDimensions(e.currentTarget.innerWidth))} />
<AnimatedSelection hasFocus={$hasFocus}>
<Container
{disabled}
@@ -50,12 +70,13 @@
'h-32 w-56': size === 'sm' && orientation === 'landscape',
'w-44 h-64': size === 'md' && orientation === 'portrait',
'h-44 w-80': size === 'md' && orientation === 'landscape',
'w-60 h-96': size === 'lg' && orientation === 'portrait',
// 'w-60 h-96': size === 'lg' && orientation === 'portrait',
'h-60 w-96': size === 'lg' && orientation === 'landscape',
'w-full h-96': size === 'dynamic',
'shadow-lg': shadow
}
)}
style={`width: ${dimensions.width}px; height: ${dimensions.height}px;`}
bind:hasFocus
>
<LazyImg

View File

@@ -43,14 +43,20 @@
>
<IconButton
on:click={() => {
carousel?.scrollTo({ left: scrollX - carousel?.clientWidth * 0.8, behavior: 'smooth' });
carousel?.scrollTo({
left: scrollX - (carousel?.clientWidth - 2 * 128 + 32),
behavior: 'smooth'
});
}}
>
<ChevronLeft size={20} />
</IconButton>
<IconButton
on:click={() => {
carousel?.scrollTo({ left: scrollX + carousel?.clientWidth * 0.8, behavior: 'smooth' });
carousel?.scrollTo({
left: scrollX + (carousel?.clientWidth - 2 * 128) + 32,
behavior: 'smooth'
});
}}
>
<ChevronRight size={20} />

View File

@@ -4,9 +4,12 @@
import { modalStack } from '../Modal/modal.store';
import Dialog from './Dialog.svelte';
type ActionFn = (() => Promise<any>) | (() => any);
export let modalId: symbol;
type ActionFn = (() => Promise<any>) | (() => any);
export let header: string;
export let body: string;
export let confirm: ActionFn;
export let cancel: ActionFn = () => {};
@@ -28,10 +31,10 @@
<Dialog>
<div class="header2 mb-4">
<slot name="header" />
{header}
</div>
<div class="font-medium text-secondary-300 mb-8">
<slot />
{body}
</div>
<Container class="flex flex-col space-y-4">
<Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(confirm)}>

View File

@@ -2,14 +2,20 @@
import Modal from '../Modal/Modal.svelte';
import classNames from 'classnames';
import { fade } from 'svelte/transition';
import { modalStack } from '../Modal/modal.store';
export let size: 'sm' | 'full' = 'sm';
function handleClose() {
modalStack.closeTopmost();
}
</script>
<Modal on:back>
<div
class="h-full flex items-center justify-center bg-primary-900/75 py-20 px-32"
transition:fade={{ duration: 100 }}
on:click|self={() => handleClose()}
>
<div
class={classNames(

View File

@@ -7,11 +7,8 @@
import {
sonarrApi,
type SonarrMonitorOptions,
sonarrMonitorOptions,
type SonarrQualityProfile,
type SonarrRootFolder
sonarrMonitorOptions
} from '../../apis/sonarr/sonarr-api';
import type { TmdbSeries2 } from '../../apis/tmdb/tmdb-api';
import { TMDB_BACKDROP_SMALL } from '../../constants';
import classNames from 'classnames';
import { type BackEvent, scrollIntoView, Selectable } from '../../selectable';
@@ -27,10 +24,13 @@
monitorOptions: SonarrMonitorOptions | null;
};
export let backdropUri: string;
export let tmdbId: number;
export let title: string;
export let modalId: symbol;
export let series: TmdbSeries2;
export let onComplete: () => void = () => {};
$: backgroundUrl = TMDB_BACKDROP_SMALL + series.backdrop_path;
$: backgroundUrl = TMDB_BACKDROP_SMALL + backdropUri;
let tab: 'add-to-sonarr' | 'root-folders' | 'quality-profiles' | 'monitor-settings' =
'add-to-sonarr';
@@ -61,7 +61,7 @@
function handleAddToSonarr() {
return sonarrApi
.addToSonarr(series.id as number, {
.addToSonarr(tmdbId as number, {
rootFolderPath: $addOptionsStore.rootFolderPath || undefined,
monitorOptions: $addOptionsStore.monitorOptions || undefined,
qualityProfileId: $addOptionsStore.qualityProfileId || undefined
@@ -123,7 +123,7 @@
>
<div class="z-10 mb-8">
<div class="h-24" />
<h1 class="header2">Add {series?.name} to Sonarr?</h1>
<h1 class="header2">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>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { sonarrApi, type SonarrRelease, type SonarrSeries } from '../../apis/sonarr/sonarr-api';
import MMMainLayout from './MMMainLayout.svelte';
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';
@@ -8,13 +7,13 @@
import type { Release } from '../../apis/combined-types';
import MMSeasonSelectTab from './MMSeasonSelectTab.svelte';
export let season: number | null = null;
export let sonarrItem: SonarrSeries;
export let season: number | undefined = undefined;
export let sonarrItem: SonarrSeries | SonarrEpisode;
export let modalId: symbol;
export let hidden: boolean;
export let onGrabRelease: (release: Release) => void = () => {};
$: releases = season !== null ? getReleases(season) : null;
$: releases = getReleases(season);
let refreshDownloadsTimeout: ReturnType<typeof setTimeout>;
@@ -24,14 +23,9 @@
return r;
});
function getReleases(season: number) {
return sonarrApi.getSeasonReleases(sonarrItem.id || -1, season);
}
function getDownloads(season: number) {
return sonarrApi
.getDownloadsBySeriesId(sonarrItem.id || -1)
.then((ds) => ds.filter((d) => d.episode?.seasonNumber === season));
function getReleases(season?: number) {
if (season) return sonarrApi.getSeasonReleases(sonarrItem.id || -1, season);
else return sonarrApi.getEpisodeReleases(sonarrItem.id || -1);
}
onDestroy(() => {
@@ -40,12 +34,18 @@
</script>
<Dialog size="full" {modalId} {hidden}>
{#if !season}
{#if 'seasons' in sonarrItem && !season}
<MMSeasonSelectTab />
{:else if releases}
{:else}
<MMReleasesTab {releases} {grabRelease}>
<h1 slot="title">{sonarrItem?.title}</h1>
<h2 slot="subtitle">Season {season} Releases</h2>
<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,20 +0,0 @@
<script lang="ts">
import { type EpisodeFileResource, sonarrApi } from '../../apis/sonarr/sonarr-api';
import ConfirmDialog from '../Dialog/ConfirmDialog.svelte';
export let modalId: symbol;
export let files: EpisodeFileResource[];
export let onComplete: () => void = () => {};
function handleDeleteSeason() {
return sonarrApi.deleteSonarrEpisodes(files.map((f) => f.id || -1)).then(() => onComplete());
}
</script>
<ConfirmDialog {modalId} confirm={handleDeleteSeason}>
<h1 slot="header">Delete Season Files?</h1>
<div>
Are you sure you want to delete all {files.length} file(s) from season {files[0]?.seasonNumber}?
</div>
</ConfirmDialog>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import Dialog from '../Dialog/Dialog.svelte';
import {
type EpisodeDownload,
sonarrApi,
type SonarrEpisode
} from '../../apis/sonarr/sonarr-api';
import Button from '../Button.svelte';
import Container from '../../../Container.svelte';
import { formatSize } from '../../utils';
import { Cross1 } from 'radix-icons-svelte';
import { capitalize } from '../../utils.js';
export let download: EpisodeDownload;
export let episode: SonarrEpisode | undefined;
export let onCancel: () => void;
$: backgroundUrl = episode?.images?.[0]?.remoteUrl;
console.log(download);
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="header2">{episode?.title}</h1>
<h2 class="header1 mb-4">Season {episode?.seasonNumber} Episode {episode?.episodeNumber}</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,6 +1,10 @@
<script lang="ts">
import Dialog from '../Dialog/Dialog.svelte';
import type { EpisodeFileResource, SonarrEpisode } from '../../apis/sonarr/sonarr-api';
import {
type EpisodeFileResource,
sonarrApi,
type SonarrEpisode
} from '../../apis/sonarr/sonarr-api';
import Button from '../Button.svelte';
import Container from '../../../Container.svelte';
import { formatSize } from '../../utils';
@@ -8,7 +12,12 @@
export let file: EpisodeFileResource;
export let episode: SonarrEpisode | undefined;
export let onDelete: () => void;
$: backgroundUrl = episode?.images?.[0]?.remoteUrl;
function handleDeleteFile() {
return sonarrApi.deleteSonarrEpisode(file.id || -1).then(() => onDelete());
}
</script>
<Dialog class="flex flex-col relative">
@@ -25,7 +34,7 @@
<h1 class="header2">{episode?.title}</h1>
<h2 class="header1 mb-4">Season {episode?.seasonNumber} Episode {episode?.episodeNumber}</h2>
<div
class="grid grid-cols-[1fr_min-content] font-medium mb-16
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>
@@ -34,14 +43,12 @@
<span class="border-b border-secondary-600">{formatSize(file.size || 0)}</span>
<span>Quality</span>
<span>{file.quality?.quality?.name}</span>
<!-- <span>Asd</span>-->
<!-- <span>Asd</span>-->
</div>
<Container class="flex flex-col space-y-4">
<Button type="secondary">
<Button type="secondary" confirmDanger action={handleDeleteFile}>
<Trash size={19} slot="icon" />
Delete
Delete File
</Button>
</Container>
</div>

View File

@@ -2,17 +2,21 @@
import Container from '../../../Container.svelte';
import HeroCarousel from '../HeroCarousel/HeroCarousel.svelte';
import DetachedPage from '../DetachedPage/DetachedPage.svelte';
import { useActionRequest, useDependantRequest, useRequest } from '../../stores/data.store';
import { tmdbApi, type TmdbSeasonEpisode } from '../../apis/tmdb/tmdb-api';
import { useRequest } from '../../stores/data.store';
import { tmdbApi } from '../../apis/tmdb/tmdb-api';
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants';
import classNames from 'classnames';
import { DotFilled, Download, ExternalLink, File, Play, Plus, Trash } from 'radix-icons-svelte';
import { Cross1, DotFilled, ExternalLink, Play, Plus, Trash } from 'radix-icons-svelte';
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
import { type EpisodeFileResource, sonarrApi } from '../../apis/sonarr/sonarr-api';
import {
type EpisodeDownload,
type EpisodeFileResource,
sonarrApi
} from '../../apis/sonarr/sonarr-api';
import Button from '../Button.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
import { createModal, modalStack } from '../Modal/modal.store';
import { derived, get, writable } from 'svelte/store';
import { get } from 'svelte/store';
import { scrollIntoView, useRegistrar } from '../../selectable';
import ScrollHelper from '../ScrollHelper.svelte';
import Carousel from '../Carousel/Carousel.svelte';
@@ -21,9 +25,10 @@
import EpisodeGrid from './EpisodeGrid.svelte';
import { formatSize } from '../../utils';
import FileDetailsDialog from './FileDetailsDialog.svelte';
import ConfirmDeleteSeasonDialog from './ConfirmDeleteSeasonDialog.svelte';
import SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte';
import MMAddToSonarrDialog from '../MediaManagerModal/MMAddToSonarrDialog.svelte';
import ConfirmDialog from '../Dialog/ConfirmDialog.svelte';
import DownloadDetailsDialog from './DownloadDetailsDialog.svelte';
export let id: string;
@@ -34,20 +39,19 @@
let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, Number(id));
// @ts-ignore
$: localFilesP = sonarrItem && getLocalFiles();
$: localFileSeasons = localFilesP.then((files) => [
...new Set(files.map((item) => item.seasonNumber || -1))
]);
$: sonarrEpisodes = Promise.all([sonarrItem, localFileSeasons])
$: sonarrDownloads = getDownloads(sonarrItem);
$: sonarrFiles = getFiles(sonarrItem);
$: sonarrSeasonNumbers = Promise.all([sonarrFiles, sonarrDownloads]).then(
([files, downloads]) => [
...new Set(files.map((item) => item.seasonNumber || -1)),
...new Set(downloads.map((item) => item.seasonNumber || -1))
]
);
$: sonarrEpisodes = Promise.all([sonarrItem, sonarrSeasonNumbers])
.then(([item, seasons]) =>
Promise.all(seasons.map((s) => sonarrApi.getEpisodes(item?.id || -1, s)))
)
.then((items) => items.flat());
$: localFilesP.then(console.log);
$: sonarrEpisodes.then(console.log);
$: sonarrItem.then(console.log);
$: localFileSeasons.then(console.log);
const jellyfinSeries = getJellyfinSeries(id);
@@ -71,11 +75,7 @@
return jellyfinApi.getLibraryItemFromTmdbId(id);
}
function getLocalFiles() {
return sonarrItem.then((item) =>
item ? sonarrApi.getFilesBySeriesId(item?.id || -1) : Promise.resolve([])
);
}
const onGrabRelease = () => setTimeout(() => (sonarrDownloads = getDownloads(sonarrItem)), 8000);
function handleAddedToSonarr() {
sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
@@ -84,7 +84,8 @@
sonarrItem &&
createModal(SeasonMediaManagerModal, {
season: 1,
sonarrItem
sonarrItem,
onGrabRelease
})
);
}
@@ -95,7 +96,8 @@
if (sonarrItem) {
createModal(SeasonMediaManagerModal, {
season,
sonarrItem
sonarrItem,
onGrabRelease
});
} else if (tmdbSeries) {
createModal(MMAddToSonarrDialog, {
@@ -107,6 +109,36 @@
}
});
}
async function getFiles(item: typeof sonarrItem) {
return item.then((item) => (item ? sonarrApi.getFilesBySeriesId(item?.id || -1) : []));
}
async function getDownloads(item: typeof sonarrItem) {
return item.then((item) => (item ? sonarrApi.getDownloadsBySeriesId(item?.id || -1) : []));
}
function createConfirmDeleteSeasonDialog(files: EpisodeFileResource[]) {
createModal(ConfirmDialog, {
header: 'Delete Season Files?',
body: `Are you sure you want to delete all ${files.length} file(s) from season ${files[0]?.seasonNumber}?`,
confirm: () =>
sonarrApi
.deleteSonarrEpisodes(files.map((f) => f.id || -1))
.then(() => (sonarrFiles = getFiles(sonarrItem)))
});
}
function createConfirmCancelDownloadsDialog(downloads: EpisodeDownload[]) {
createModal(ConfirmDialog, {
header: 'Cancel Season Downloads?',
body: `Are you sure you want to cancel all ${downloads.length} download(s) from season ${downloads[0]?.seasonNumber}?`,
confirm: () =>
sonarrApi
.cancelDownloads(downloads.map((f) => f.id || -1))
.then(() => (sonarrDownloads = getDownloads(sonarrItem)))
});
}
</script>
<DetachedPage let:handleGoBack let:registrar>
@@ -273,49 +305,34 @@
</div>
</Container>
{/await}
{#await Promise.all( [localFilesP, localFileSeasons, sonarrEpisodes] ) then [localFiles, seasons, episodes]}
{#if localFiles?.length}
{#await Promise.all( [sonarrSeasonNumbers, sonarrFiles, sonarrEpisodes, sonarrDownloads] ) then [seasons, files, episodes, downloads]}
{#if files?.length}
<Container
class="flex-1 bg-secondary-950 pt-8 pb-16 px-32 flex flex-col"
on:enter={scrollIntoView({ top: 0 })}
on:enter={scrollIntoView({ top: 32 })}
>
<!-- <h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">Local Files</h1>-->
<div class="space-y-16">
{#each seasons as season}
{@const files = localFiles.filter((f) => f.seasonNumber === season)}
<div class="">
{@const seasonEpisodes = episodes.filter((e) => e.seasonNumber === season)}
{@const seasonFiles = files.filter((f) => f.seasonNumber === season)}
{@const seasonDownloads = downloads.filter((d) => d.seasonNumber === season)}
<div>
<div class="flex justify-between">
<h2 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">
Season {season} Files
</h2>
<!-- <Checkbox-->
<!-- checked={Object.keys(selectedFiles).length-->
<!-- ? Object.values(selectedFiles).every(({ file, selected }) => {-->
<!-- return file.seasonNumber === season ? selected : true;-->
<!-- })-->
<!-- : false}-->
<!-- on:change={({ detail }) => {-->
<!-- selectedFiles = Object.fromEntries(-->
<!-- Object.entries(selectedFiles).map(([key, value]) => {-->
<!-- if (value.file.seasonNumber === season) {-->
<!-- value.selected = detail;-->
<!-- }-->
<!-- return [key, value];-->
<!-- })-->
<!-- );-->
<!-- }}-->
<!-- />-->
</div>
<div class="grid grid-cols-2 gap-8">
{#each files as file}
{@const episode = episodes.find(
(e) => e.episodeFileId !== undefined && e.episodeFileId === file.id
)}
<Container direction="grid" gridCols={2} class="grid grid-cols-2 gap-8">
{#each seasonEpisodes as episode}
{@const file = seasonFiles.find((f) => f.id === episode.episodeFileId)}
{@const download = seasonDownloads.find((d) => d.episodeId === episode.id)}
<Container
class={classNames(
'flex space-x-8 items-center text-zinc-300 font-medium',
'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,
@@ -324,38 +341,76 @@
// 'bg-secondary-800 focus-within:border-zinc-300': !selected
}
)}
on:clickOrSelect={() =>
modalStack.create(FileDetailsDialog, { file, episode })}
on:clickOrSelect={() => {
if (file)
modalStack.create(FileDetailsDialog, {
file,
episode,
onDelete: () => (sonarrFiles = getFiles(sonarrItem))
});
else if (download)
modalStack.create(DownloadDetailsDialog, {
download,
episode,
onCancel: () => (sonarrDownloads = getDownloads(sonarrItem))
});
}}
on:enter={scrollIntoView({ vertical: 128 })}
focusOnClick
>
{#if download}
<div
class="absolute inset-0 bg-secondary-50/10"
style={`width: ${
(((download.size || 0) - (download.sizeleft || 0)) /
(download.size || 1)) *
100
}%`}
/>
{/if}
<div class="flex-1">
<h1 class="text-lg">
{episode?.episodeNumber}. {episode?.title}
</h1>
</div>
{#if file}
<div>
{file.mediaInfo?.runTime}
{file?.mediaInfo?.runTime}
</div>
<div>
{formatSize(file.size || 0)}
{formatSize(file?.size || 0)}
</div>
<div>
{file.quality?.quality?.name}
{file?.quality?.quality?.name}
</div>
{:else if download}
<div>
{formatSize((download?.size || 0) - (download?.sizeleft || 1))} / {formatSize(
download?.size || 0
)}
</div>
<div>
{download?.quality?.quality?.name}
</div>
{/if}
</Container>
{/each}
</div>
</Container>
<Container direction="horizontal" class="flex mt-8">
<Button
on:clickOrSelect={() =>
createModal(ConfirmDeleteSeasonDialog, {
files: files,
onComplete: () => (localFilesP = getLocalFiles())
})}
>
{#if seasonFiles?.length}
<Button on:clickOrSelect={() => createConfirmDeleteSeasonDialog(seasonFiles)}>
<Trash size={19} slot="icon" />
Delete Season Files
</Button>
{/if}
{#if seasonDownloads?.length}
<Button
on:clickOrSelect={() => createConfirmCancelDownloadsDialog(seasonDownloads)}
>
<Cross1 size={19} slot="icon" />
Cancel Season Downloads
</Button>
{/if}
</Container>
</div>
{/each}

View File

@@ -3,17 +3,35 @@
import { tmdbApi } from '../apis/tmdb/tmdb-api';
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
import { useActionRequest, useDependantRequest, useRequest } from '../stores/data.store';
import { TMDB_IMAGES_ORIGINAL } from '../constants';
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../constants';
import classNames from 'classnames';
import { Check, DotFilled, Download, File, Play, Trash } from 'radix-icons-svelte';
import {
Check,
DotFilled,
Download,
ExternalLink,
File,
Play,
Plus,
Trash
} from 'radix-icons-svelte';
import HeroInfoTitle from '../components/HeroInfo/HeroInfoTitle.svelte';
import Button from '../components/Button.svelte';
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api';
import { playerState } from '../components/VideoPlayer/VideoPlayer';
import { formatSize, timeout } from '../utils';
import { tick } from 'svelte';
import { openEpisodeMediaManager } from '../components/Modal/modal.store';
import { createModal, openEpisodeMediaManager } from '../components/Modal/modal.store';
import ButtonGhost from '../components/Ghosts/ButtonGhost.svelte';
import {
type EpisodeFileResource,
sonarrApi,
type SonarrEpisode,
type SonarrSeries
} from '../apis/sonarr/sonarr-api';
import MMAddToSonarrDialog from '../components/MediaManagerModal/MMAddToSonarrDialog.svelte';
import SeasonMediaManagerModal from '../components/MediaManagerModal/SeasonMediaManagerModal.svelte';
import ConfirmDialog from '../components/Dialog/ConfirmDialog.svelte';
export let id: string; // Series ID
export let season: string;
@@ -22,6 +40,10 @@
let isWatched = false;
const tmdbEpisode = tmdbApi.getEpisode(Number(id), Number(season), Number(episode));
let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
$: sonarrEpisode = getSonarrEpisode(sonarrItem);
let sonarrFiles = getFiles(sonarrItem, sonarrEpisode);
const jellyfinSeries = jellyfinApi.getLibraryItemFromTmdbId(id);
let jellyfinEpisode = jellyfinSeries.then((series) =>
jellyfinApi.getEpisode(series?.Id || '', Number(season), Number(episode))
@@ -43,6 +65,59 @@
jellyfinEpisode.then((e) => {
isWatched = e?.UserData?.Played || false;
});
async function getSonarrEpisode(sonarrItem: Promise<SonarrSeries | undefined>) {
return sonarrItem.then((sonarrItem) => {
if (!sonarrItem?.id) return;
return sonarrApi
.getEpisodes(sonarrItem.id, Number(season))
.then((episodes) => episodes.find((e) => e.episodeNumber === Number(episode)));
});
}
function handleRequestEpisode() {
return Promise.all([sonarrEpisode, tmdbEpisode]).then(([sonarrEpisode, tmdbEpisode]) => {
if (sonarrEpisode) {
createModal(SeasonMediaManagerModal, {
sonarrItem: sonarrEpisode,
onGrabRelease: () => {} // TODO
});
} else if (tmdbEpisode) {
createModal(MMAddToSonarrDialog, {
tmdbId: Number(id),
backdropUri: tmdbEpisode.still_path || '',
title: tmdbEpisode.name || '',
onComplete: () => (sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id)))
});
} else {
console.error('No series found');
}
});
}
function createConfirmDeleteFiles(files: EpisodeFileResource[]) {
createModal(ConfirmDialog, {
header: 'Delete Season Files?',
body: `Are you sure you want to delete all ${files.length} file(s)?`,
confirm: () =>
sonarrApi
.deleteSonarrEpisodes(files.map((f) => f.id || -1))
.then(() => (sonarrFiles = getFiles(sonarrItem, sonarrEpisode)))
});
}
function getFiles(
sonarrItem: Promise<SonarrSeries | undefined>,
sonarrEpisode: Promise<SonarrEpisode | undefined>
) {
return Promise.all([sonarrItem, sonarrEpisode]).then(([sonarrItem, sonarrEpisode]) => {
if (!sonarrItem?.id) return [];
return sonarrApi
.getFilesBySeriesId(sonarrItem.id)
.then((files) => files.filter((f) => sonarrEpisode?.episodeFileId === f.id));
});
}
</script>
<DetachedPage let:handleGoBack let:registrar>
@@ -103,19 +178,21 @@
<div class="text-stone-300 font-medium line-clamp-3 opacity-75 max-w-4xl mt-4">
{tmdbEpisode?.overview}
</div>
<Container direction="horizontal" class="flex mt-8">
{#await jellyfinEpisode}
<Container direction="horizontal" class="flex mt-8 space-x-4">
{#await Promise.all([jellyfinEpisode, sonarrEpisode])}
<ButtonGhost>Play</ButtonGhost>
<ButtonGhost>Play</ButtonGhost>
{:then jEpisode}
<ButtonGhost>Manage Media</ButtonGhost>
<ButtonGhost>Delete Files</ButtonGhost>
{:then [jellyfinEpisode]}
{#if jellyfinEpisode?.MediaSources?.length}
<Button
class="mr-4"
on:clickOrSelect={() => jEpisode?.Id && playerState.streamJellyfinId(jEpisode.Id)}
on:clickOrSelect={() =>
jellyfinEpisode?.Id && playerState.streamJellyfinId(jellyfinEpisode.Id)}
>
Play
<Play size={19} slot="icon" />
</Button>
<Button class="mr-4" disabled={$markAsLoading} on:clickOrSelect={toggleMarkAs}>
<Button disabled={$markAsLoading} on:clickOrSelect={toggleMarkAs}>
{#if isWatched}
Mark as Unwatched
{:else}
@@ -123,14 +200,36 @@
{/if}
<Check slot="icon" size={19} />
</Button>
{:else}
<Button action={handleRequestEpisode}>
Request
<Plus size={19} slot="icon" />
</Button>
{/if}
{/await}
<Button
class="mr-4"
on:clickOrSelect={() =>
openEpisodeMediaManager(Number(id), Number(season), Number(episode))}
>Manage Media <File slot="icon" size={19} /></Button
<!-- <Button-->
<!-- on:clickOrSelect={() =>-->
<!-- openEpisodeMediaManager(Number(id), Number(season), Number(episode))}-->
<!-- >Manage Media <File slot="icon" size={19} /></Button-->
<!-- >-->
{#await sonarrFiles then files}
{#if files?.length}
<Button on:clickOrSelect={() => createConfirmDeleteFiles(files)}
>Delete Files <Trash slot="icon" size={19} /></Button
>
<Button class="mr-4">Delete Files <Trash slot="icon" size={19} /></Button>
{/if}
{/await}
{#if PLATFORM_WEB}
<Button>
Open In TMDB
<ExternalLink size={19} slot="icon-after" />
</Button>
<Button>
Open In Jellyfin
<ExternalLink size={19} slot="icon-after" />
</Button>
{/if}
</Container>
</Container>
{/await}