feature: add season download and request functionality

This commit is contained in:
WonderAliceUwU committed 2026-07-07 16:02:41 +02:00
1 parent 98db35d367
commit 55956bc52d
6 files changed
+1227 -353

No files matched your search

+27 -1
View File
@@ -1,7 +1,15 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { defaultConfig, type AppConfig, type AuthPayload, type EpisodeWatchStateRequest, type TitleDetailsRequest } from '../shared/contracts.js';
import {
defaultConfig,
type AppConfig,
type AuthPayload,
type EpisodeWatchStateRequest,
type SeasonDeletePayload,
type SeasonRequestPayload,
type TitleDetailsRequest
} from '../shared/contracts.js';
import { decryptJson, deriveEncryptionKey, encryptJson, randomToken, verifyPassword, createPasswordRecord } from './lib/security.js';
import { loadState, readAdmin, readEncryptedConfig, saveAdminAndConfig, updateEncryptedConfig } from './lib/store.js';
import {
@@ -11,11 +19,13 @@ import {
getHubPage,
getTitleDetails,
getTmdbLogos,
deleteSeasonFiles,
getServiceOptions,
loadJellyfinUsers,
proxyJellyfinImage,
removeDownload,
requestMovie,
requestSeason,
requestSeries,
setEpisodeWatchState,
sanitizeConfig
@@ -257,6 +267,22 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
return;
}
if (url.pathname === '/api/requests/season' && method === 'POST') {
const config = await readConfig(session!.encryptionKey);
const body = await readJson<SeasonRequestPayload>(request);
await requestSeason(config, body);
sendJson(response, 200, { ok: true });
return;
}
if (url.pathname === '/api/seasons/delete' && method === 'POST') {
const config = await readConfig(session!.encryptionKey);
const body = await readJson<SeasonDeletePayload>(request);
await deleteSeasonFiles(config, body);
sendJson(response, 200, { ok: true });
return;
}
if (url.pathname.startsWith('/api/assets/jellyfin/') && method === 'GET') {
const config = await readConfig(session!.encryptionKey);
const itemId = decodeURIComponent(url.pathname.replace('/api/assets/jellyfin/', ''));
+527 -27
View File
@@ -3,16 +3,18 @@ import type {
ConnectionStatus,
DashboardResponse,
DownloadItem,
EpisodeWatchStateRequest,
HubPageResponse,
HubSection,
MediaCard,
SeasonDeletePayload,
SeasonRequestPayload,
SelectOption,
ServiceOptionsResponse,
TitleDetailsEpisode,
TitleDetailsRequest,
TitleDetailsResponse,
TitleDetailsSeason,
EpisodeWatchStateRequest
TitleDetailsSeason
} from '../../shared/contracts.js';
const TMDB_IMAGE = 'https://image.tmdb.org/t/p';
@@ -48,6 +50,7 @@ interface JellyfinItem {
interface SonarrQueueRecord {
id?: number;
seriesId?: number;
title?: string;
downloadId?: string;
series?: { title?: string; tmdbId?: number; images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }> };
@@ -59,6 +62,18 @@ interface SonarrQueueRecord {
timeleft?: string;
}
interface SonarrCommandRecord {
id?: number;
name?: string;
commandName?: string;
status?: string;
state?: string;
body?: {
seriesId?: number;
seasonNumber?: number;
};
}
interface RadarrQueueRecord {
id?: number;
title?: string;
@@ -76,6 +91,59 @@ interface DownloadMetadataHint {
subtitle?: string;
}
interface SonarrSeriesRecord {
id?: number;
title?: string;
tmdbId?: number;
tvdbId?: number;
}
interface SonarrEpisodeRecord {
id?: number;
seriesId?: number;
seasonNumber?: number;
episodeNumber?: number;
title?: string;
overview?: string;
airDateUtc?: string;
runtime?: number;
monitored?: boolean;
hasFile?: boolean;
episodeFileId?: number;
}
interface SonarrEpisodeFileRecord {
id?: number;
seriesId?: number;
seasonNumber?: number;
relativePath?: string;
path?: string;
size?: number;
}
interface TmdbSeasonSummary {
seasonNumber: number;
name: string;
episodeCount?: number;
}
interface TmdbEpisodeRecord {
episode_number?: number;
name?: string;
overview?: string;
still_path?: string | null;
air_date?: string;
runtime?: number;
}
interface SeriesSeasonDescriptor {
id: string;
title: string;
seasonNumber?: number;
episodeCount?: number;
jellyfinSeasonId?: string;
}
const ANIME_PROVIDERS = '337|8|9|430|283';
const DORAMA_PROVIDERS = '344|337|8|9|464';
@@ -373,6 +441,7 @@ export async function getTitleDetails(
const item = card.providerId ? await getJellyfinItemDetails(config, card.providerId) : null;
const seriesItem = await resolveSeriesItem(config, card, item);
const primaryItem = seriesItem || item;
const isSeries = card.mediaType === 'series' || card.mediaType === 'tv' || seriesItem?.Type === 'Series' || item?.Type === 'Episode';
const detailMediaType: 'movie' | 'tv' = primaryItem?.Type === 'Movie' || card.mediaType === 'movie' ? 'movie' : 'tv';
const tmdbId = Number(primaryItem?.ProviderIds?.Tmdb) || card.tmdbId;
const originalLanguage = card.originalLanguage;
@@ -381,21 +450,21 @@ export async function getTitleDetails(
tmdbId ? getPreferredTitleLogo(config, tmdbId, detailMediaType) : Promise.resolve('')
]);
const title = seriesItem?.Name || primaryItem?.Name || card.title;
const overview = seriesItem?.Overview || primaryItem?.Overview || card.overview;
const fallbackTitle = seriesItem?.Name || primaryItem?.Name || card.title;
const fallbackOverview = seriesItem?.Overview || primaryItem?.Overview || card.overview;
const subtitle = buildTitleSubtitle(seriesItem || item, card);
const posterUrl = resolvePrimaryPoster(seriesItem || item, card);
const effectiveBackdropUrl = backdropUrl || resolveBackdrop(seriesItem || item, card);
if (!seriesItem?.Id) {
if (!isSeries) {
return {
id: card.id,
tmdbId,
providerId: item?.Id || card.providerId,
playTargetId: item?.Id || card.providerId,
availableInJellyfin: Boolean(item?.Id || card.providerId),
title,
overview,
title: fallbackTitle,
overview: fallbackOverview,
subtitle,
mediaType: 'movie',
posterUrl,
@@ -406,12 +475,36 @@ export async function getTitleDetails(
};
}
const seasons = await getSeriesSeasons(config, seriesItem.Id);
const nextEpisode = await getNextEpisodeToPlay(config, seriesItem.Id);
const selectedSeason = chooseSelectedSeason(seasons, seasonId, item, nextEpisode);
const episodes = selectedSeason?.id
? await getSeasonEpisodes(config, seriesItem.Id, selectedSeason.id)
: [];
const [tmdbSeries, jellyfinSeasons, nextEpisode] = await Promise.all([
tmdbId ? getTmdbSeriesSummary(config, tmdbId) : Promise.resolve(null),
seriesItem?.Id ? getJellyfinSeriesSeasons(config, seriesItem.Id) : Promise.resolve([]),
seriesItem?.Id ? getNextEpisodeToPlay(config, seriesItem.Id) : Promise.resolve(null)
]);
const title = seriesItem?.Name || String(tmdbSeries?.name || '') || primaryItem?.Name || card.title;
const overview = seriesItem?.Overview || String(tmdbSeries?.overview || '') || primaryItem?.Overview || card.overview;
const sonarrSeries = await getSonarrSeriesRecord(
config,
Number(asRecord(tmdbSeries?.external_ids).tvdb_id || 0),
tmdbId
);
const sonarrContext = sonarrSeries?.id
? await getSonarrSeriesContext(config, sonarrSeries.id)
: { episodes: [], queue: [], commands: [] };
const seasonDescriptors = buildSeriesSeasonDescriptors(tmdbSeries, jellyfinSeasons);
const selectedSeason = chooseSelectedSeason(seasonDescriptors, seasonId, item, nextEpisode);
const episodeGroups = await Promise.all(
seasonDescriptors.map((season) => buildSeasonEpisodes(
config,
tmdbId,
season,
seriesItem?.Id,
Boolean(sonarrSeries?.id),
sonarrContext.episodes,
sonarrContext.queue
))
);
const episodes = episodeGroups.flat();
const seasons = buildTitleSeasons(seasonDescriptors, episodes, sonarrContext.episodes, sonarrContext.queue, sonarrContext.commands);
const resolvedNextEpisode = nextEpisode && episodes.some((episode) => episode.id === nextEpisode.id)
? episodes.find((episode) => episode.id === nextEpisode.id) || nextEpisode
@@ -420,9 +513,10 @@ export async function getTitleDetails(
return {
id: card.id,
tmdbId,
providerId: seriesItem.Id,
playTargetId: resolvedNextEpisode?.id || seriesItem.Id,
availableInJellyfin: Boolean(seriesItem.Id),
providerId: seriesItem?.Id || card.providerId,
sonarrSeriesId: sonarrSeries?.id,
playTargetId: resolvedNextEpisode?.id || seriesItem?.Id,
availableInJellyfin: Boolean(seriesItem?.Id),
title,
overview,
subtitle,
@@ -691,7 +785,14 @@ async function getPreferredTitleLogo(
return selectedLogo?.file_path ? `${TMDB_IMAGE}/original${selectedLogo.file_path}` : '';
}
async function getSeriesSeasons(config: AppConfig, seriesId: string): Promise<TitleDetailsSeason[]> {
async function getTmdbSeriesSummary(config: AppConfig, tmdbId: number): Promise<Record<string, unknown> | null> {
return tmdbFetch(
config,
`/tv/${tmdbId}?append_to_response=external_ids&language=${encodeURIComponent(config.preferences.language)}`
);
}
async function getJellyfinSeriesSeasons(config: AppConfig, seriesId: string): Promise<SeriesSeasonDescriptor[]> {
try {
const seasons = await jellyfinFetch<JellyfinItem[]>(
config,
@@ -701,27 +802,24 @@ async function getSeriesSeasons(config: AppConfig, seriesId: string): Promise<Ti
return seasons
.filter((season) => season.Id)
.map((season) => ({
id: String(season.Id),
id: buildSeasonId(typeof season.IndexNumber === 'number' ? season.IndexNumber : undefined, String(season.Id)),
title: season.Name || (typeof season.IndexNumber === 'number' ? `Season ${season.IndexNumber}` : 'Season'),
seasonNumber: typeof season.IndexNumber === 'number' ? season.IndexNumber : undefined,
episodeCount: typeof season.ChildCount === 'number' ? season.ChildCount : undefined
episodeCount: typeof season.ChildCount === 'number' ? season.ChildCount : undefined,
jellyfinSeasonId: String(season.Id)
}))
.sort((left, right) => {
const leftNumber = left.seasonNumber === 0 ? Number.MAX_SAFE_INTEGER : left.seasonNumber ?? Number.MAX_SAFE_INTEGER - 1;
const rightNumber = right.seasonNumber === 0 ? Number.MAX_SAFE_INTEGER : right.seasonNumber ?? Number.MAX_SAFE_INTEGER - 1;
return leftNumber - rightNumber;
});
.sort(sortSeasonDescriptors);
} catch {
return [];
}
}
function chooseSelectedSeason(
seasons: TitleDetailsSeason[],
seasons: SeriesSeasonDescriptor[],
requestedSeasonId: string | undefined,
item: JellyfinItem | null,
nextEpisode?: TitleDetailsEpisode | null
): TitleDetailsSeason | undefined {
): SeriesSeasonDescriptor | undefined {
if (requestedSeasonId) {
const requestedSeason = seasons.find((season) => season.id === requestedSeasonId);
if (requestedSeason) {
@@ -746,7 +844,11 @@ function chooseSelectedSeason(
return seasons.find((season) => season.seasonNumber && season.seasonNumber > 0) || seasons[0];
}
async function getSeasonEpisodes(config: AppConfig, seriesId: string, seasonId: string): Promise<TitleDetailsEpisode[]> {
async function getJellyfinSeasonEpisodes(
config: AppConfig,
seriesId: string,
seasonId: string
): Promise<TitleDetailsEpisode[]> {
try {
const episodes = await jellyfinFetch<JellyfinItem[]>(
config,
@@ -762,6 +864,342 @@ async function getSeasonEpisodes(config: AppConfig, seriesId: string, seasonId:
}
}
async function getTmdbSeasonEpisodes(
config: AppConfig,
tmdbId: number,
seasonNumber: number
): Promise<TmdbEpisodeRecord[]> {
const response = await tmdbFetch(
config,
`/tv/${tmdbId}/season/${seasonNumber}?language=${encodeURIComponent(config.preferences.language)}`
);
return asArray(response?.episodes)
.map((entry) => asRecord(entry))
.map((episode) => ({
episode_number: Number(episode.episode_number || 0) || undefined,
name: typeof episode.name === 'string' ? episode.name : undefined,
overview: typeof episode.overview === 'string' ? episode.overview : undefined,
still_path: typeof episode.still_path === 'string' ? episode.still_path : episode.still_path === null ? null : undefined,
air_date: typeof episode.air_date === 'string' ? episode.air_date : undefined,
runtime: Number(episode.runtime || 0) || undefined
}));
}
async function getSonarrSeriesRecord(
config: AppConfig,
tvdbId: number,
tmdbId?: number
): Promise<SonarrSeriesRecord | null> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey || !tvdbId) {
return null;
}
const response = await fetchJson<SonarrSeriesRecord[]>(
`${config.sonarr.baseUrl}/api/v3/series?tvdbId=${encodeURIComponent(String(tvdbId))}`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
return response.find((series) => series.id && (!tmdbId || !series.tmdbId || series.tmdbId === tmdbId)) || response[0] || null;
}
async function getSonarrEpisodesBySeriesId(config: AppConfig, seriesId: number): Promise<SonarrEpisodeRecord[]> {
return fetchJson<SonarrEpisodeRecord[]>(
`${config.sonarr.baseUrl}/api/v3/episode?seriesId=${encodeURIComponent(String(seriesId))}`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
}
async function getSonarrEpisodeFilesBySeriesId(config: AppConfig, seriesId: number): Promise<SonarrEpisodeFileRecord[]> {
return fetchJson<SonarrEpisodeFileRecord[]>(
`${config.sonarr.baseUrl}/api/v3/episodefile?seriesId=${encodeURIComponent(String(seriesId))}`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
}
async function getSonarrSeriesContext(
config: AppConfig,
seriesId: number
): Promise<{ episodes: SonarrEpisodeRecord[]; queue: SonarrQueueRecord[]; commands: SonarrCommandRecord[] }> {
const [episodes, queue, commands] = await Promise.all([
getSonarrEpisodesBySeriesId(config, seriesId),
getSonarrQueue(config),
getSonarrCommands(config)
]);
return {
episodes,
queue: queue.filter((entry) => entry.seriesId === seriesId),
commands: commands.filter((command) => command.body?.seriesId === seriesId)
};
}
async function getSonarrCommands(config: AppConfig): Promise<SonarrCommandRecord[]> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
return [];
}
try {
return await fetchJson<SonarrCommandRecord[]>(
`${config.sonarr.baseUrl}/api/v3/command`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
} catch {
return [];
}
}
function buildSeriesSeasonDescriptors(
tmdbSeries: Record<string, unknown> | null,
jellyfinSeasons: SeriesSeasonDescriptor[]
): SeriesSeasonDescriptor[] {
const bySeasonNumber = new Map(
jellyfinSeasons
.filter((season) => typeof season.seasonNumber === 'number')
.map((season) => [season.seasonNumber as number, season] as const)
);
const tmdbSeasons = asArray(tmdbSeries?.seasons)
.map((entry) => asRecord(entry))
.map((season) => ({
seasonNumber: Number(season.season_number || 0),
name: typeof season.name === 'string' ? season.name : '',
episodeCount: Number(season.episode_count || 0) || undefined
} satisfies TmdbSeasonSummary))
.filter((season) => Number.isFinite(season.seasonNumber));
if (tmdbSeasons.length) {
return tmdbSeasons
.filter((season) => {
const jellyfinSeason = bySeasonNumber.get(season.seasonNumber);
return (season.episodeCount ?? jellyfinSeason?.episodeCount ?? 0) > 0;
})
.map((season) => {
const jellyfinSeason = bySeasonNumber.get(season.seasonNumber);
return {
id: buildSeasonId(season.seasonNumber),
title: season.name || `Season ${season.seasonNumber}`,
seasonNumber: season.seasonNumber,
episodeCount: season.episodeCount ?? jellyfinSeason?.episodeCount,
jellyfinSeasonId: jellyfinSeason?.jellyfinSeasonId
};
})
.sort(sortSeasonDescriptors);
}
return jellyfinSeasons
.filter((season) => (season.episodeCount ?? 0) > 0)
.sort(sortSeasonDescriptors);
}
async function buildSeasonEpisodes(
config: AppConfig,
tmdbId: number | undefined,
season: SeriesSeasonDescriptor,
jellyfinSeriesId: string | undefined,
isManagedInSonarr: boolean,
sonarrEpisodes: SonarrEpisodeRecord[],
sonarrQueue: SonarrQueueRecord[]
): Promise<TitleDetailsEpisode[]> {
const seasonNumber = season.seasonNumber;
const [tmdbEpisodes, jellyfinEpisodes] = await Promise.all([
tmdbId && typeof seasonNumber === 'number'
? getTmdbSeasonEpisodes(config, tmdbId, seasonNumber)
: Promise.resolve([]),
jellyfinSeriesId && season.jellyfinSeasonId
? getJellyfinSeasonEpisodes(config, jellyfinSeriesId, season.jellyfinSeasonId)
: Promise.resolve([])
]);
const tmdbByEpisodeNumber = new Map(
tmdbEpisodes
.filter((episode) => typeof episode.episode_number === 'number')
.map((episode) => [episode.episode_number as number, episode] as const)
);
const jellyfinByEpisodeNumber = new Map(
jellyfinEpisodes
.filter((episode) => typeof episode.episodeNumber === 'number')
.map((episode) => [episode.episodeNumber as number, episode] as const)
);
const sonarrByEpisodeNumber = new Map(
sonarrEpisodes
.filter((episode) => episode.seasonNumber === seasonNumber && typeof episode.episodeNumber === 'number')
.map((episode) => [episode.episodeNumber as number, episode] as const)
);
const downloadingEpisodes = new Set(
sonarrQueue
.filter((entry) => entry.episode?.seasonNumber === seasonNumber && typeof entry.episode?.episodeNumber === 'number')
.map((entry) => entry.episode?.episodeNumber as number)
);
const episodeNumbers = new Set<number>([
...tmdbByEpisodeNumber.keys(),
...jellyfinByEpisodeNumber.keys(),
...sonarrByEpisodeNumber.keys()
]);
return [...episodeNumbers]
.sort((left, right) => left - right)
.map((episodeNumber) => {
const tmdbEpisode = tmdbByEpisodeNumber.get(episodeNumber);
const jellyfinEpisode = jellyfinByEpisodeNumber.get(episodeNumber);
const sonarrEpisode = sonarrByEpisodeNumber.get(episodeNumber);
const airDate = tmdbEpisode?.air_date || sonarrEpisode?.airDateUtc;
const isReleased = !airDate || new Date(airDate).getTime() <= Date.now();
const hasFile = Boolean(sonarrEpisode?.hasFile || sonarrEpisode?.episodeFileId);
const isAvailableInJellyfin = Boolean(jellyfinEpisode?.id);
const isDownloading = downloadingEpisodes.has(episodeNumber);
const availability: TitleDetailsEpisode['availability'] = !isReleased
? 'unaired'
: isAvailableInJellyfin || hasFile
? 'available'
: isDownloading
? 'downloading'
: 'missing';
return {
id: jellyfinEpisode?.id || buildEpisodeId(seasonNumber, episodeNumber),
seasonId: season.id,
title: jellyfinEpisode?.title || sonarrEpisode?.title || tmdbEpisode?.name || `Episode ${episodeNumber}`,
overview: jellyfinEpisode?.overview || sonarrEpisode?.overview || tmdbEpisode?.overview || '',
subtitle: [
typeof seasonNumber === 'number' ? `Season ${seasonNumber}` : '',
`Episode ${episodeNumber}`
].filter(Boolean).join(', '),
imageUrl: jellyfinEpisode?.imageUrl || (tmdbEpisode?.still_path ? `${TMDB_IMAGE}/w780${tmdbEpisode.still_path}` : ''),
airDate,
seasonNumber,
episodeNumber,
runtimeMinutes: jellyfinEpisode?.runtimeMinutes || tmdbEpisode?.runtime,
availableInJellyfin: isAvailableInJellyfin,
availability,
canDownload: isManagedInSonarr && availability === 'missing',
canDelete: hasFile,
watched: jellyfinEpisode?.watched,
progress: jellyfinEpisode?.progress,
playbackPositionTicks: jellyfinEpisode?.playbackPositionTicks
};
});
}
function buildTitleSeasons(
seasons: SeriesSeasonDescriptor[],
allEpisodes: TitleDetailsEpisode[],
sonarrEpisodes: SonarrEpisodeRecord[],
sonarrQueue: SonarrQueueRecord[],
sonarrCommands: SonarrCommandRecord[]
): TitleDetailsSeason[] {
const seasonMap = new Map<number, TitleDetailsSeason>();
for (const season of seasons) {
const seasonQueue = sonarrQueue.filter((entry) => entry.episode?.seasonNumber === season.seasonNumber);
const availableEpisodeCount = sonarrEpisodes.filter(
(episode) => episode.seasonNumber === season.seasonNumber && (episode.hasFile || episode.episodeFileId)
).length;
const isDownloading = seasonQueue.length > 0;
const isProcessing = sonarrCommands.some((command) =>
command.body?.seasonNumber === season.seasonNumber && isActiveSeasonSearchCommand(command)
);
seasonMap.set(season.seasonNumber ?? Number.MIN_SAFE_INTEGER, {
id: season.id,
title: season.title,
seasonNumber: season.seasonNumber,
episodeCount: season.episodeCount,
availableEpisodeCount,
missingEpisodeCount: typeof season.episodeCount === 'number'
? Math.max(season.episodeCount - availableEpisodeCount, 0)
: undefined,
canDownload: false,
canDelete: availableEpisodeCount > 0,
isDownloading,
downloadStatus: isDownloading ? 'downloading' : isProcessing ? 'processing' : 'idle',
downloadProgress: isDownloading ? getSeasonQueueProgress(seasonQueue) : undefined,
downloadLabel: isDownloading ? 'Downloading' : isProcessing ? 'Processing' : undefined
});
}
for (const season of seasons) {
const seasonEpisodes = allEpisodes.filter((episode) => episode.seasonId === season.id);
if (!seasonEpisodes.length || typeof season.seasonNumber !== 'number') {
continue;
}
const current = seasonMap.get(season.seasonNumber);
if (!current) {
continue;
}
seasonMap.set(season.seasonNumber, {
...current,
episodeCount: seasonEpisodes.length || current.episodeCount,
availableEpisodeCount: seasonEpisodes.filter((episode) => episode.availability === 'available').length,
missingEpisodeCount: seasonEpisodes.filter((episode) => episode.availability === 'missing').length,
canDownload: seasonEpisodes.some((episode) => episode.canDownload),
canDelete: seasonEpisodes.some((episode) => episode.canDelete),
isDownloading: current.isDownloading || seasonEpisodes.some((episode) => episode.availability === 'downloading')
});
}
return seasons
.map((season) => seasonMap.get(season.seasonNumber ?? Number.MIN_SAFE_INTEGER))
.filter((season): season is TitleDetailsSeason => Boolean(season));
}
function buildSeasonId(seasonNumber?: number, fallback?: string): string {
return typeof seasonNumber === 'number'
? `season:${seasonNumber}`
: `season:${fallback || 'unknown'}`;
}
function buildEpisodeId(seasonNumber: number | undefined, episodeNumber: number): string {
return `episode:${typeof seasonNumber === 'number' ? seasonNumber : 'x'}:${episodeNumber}`;
}
function sortSeasonDescriptors(left: { seasonNumber?: number }, right: { seasonNumber?: number }) {
const leftNumber = left.seasonNumber === 0 ? Number.MAX_SAFE_INTEGER : left.seasonNumber ?? Number.MAX_SAFE_INTEGER - 1;
const rightNumber = right.seasonNumber === 0 ? Number.MAX_SAFE_INTEGER : right.seasonNumber ?? Number.MAX_SAFE_INTEGER - 1;
return leftNumber - rightNumber;
}
function isActiveSeasonSearchCommand(command: SonarrCommandRecord): boolean {
const name = String(command.name || command.commandName || '').toLowerCase();
const status = String(command.status || command.state || '').toLowerCase();
return name === 'seasonsearch'
&& status !== 'completed'
&& status !== 'failed'
&& status !== 'aborted'
&& status !== 'cancelled'
&& status !== 'orphaned';
}
function getSeasonQueueProgress(queue: SonarrQueueRecord[]): number | undefined {
if (!queue.length) {
return undefined;
}
const totalSize = queue.reduce((sum, item) => sum + Math.max(0, Number(item.size || 0)), 0);
const totalSizeLeft = queue.reduce((sum, item) => sum + Math.max(0, Number(item.sizeleft || 0)), 0);
if (!totalSize) {
return undefined;
}
return Math.max(0, Math.min(100, Math.round(((totalSize - totalSizeLeft) / totalSize) * 100)));
}
async function getNextEpisodeToPlay(
config: AppConfig,
seriesId: string
@@ -825,6 +1263,8 @@ function mapJellyfinEpisode(episode: JellyfinItem): TitleDetailsEpisode {
seasonNumber: episode.ParentIndexNumber,
episodeNumber: episode.IndexNumber,
runtimeMinutes: episode.RunTimeTicks ? Math.max(1, Math.round(episode.RunTimeTicks / 600000000)) : undefined,
availableInJellyfin: true,
availability: 'available',
watched: Boolean(episode.UserData?.Played),
progress: Math.max(0, Math.min(100, Math.round(episode.UserData?.PlayedPercentage || 0))) || undefined,
playbackPositionTicks: episode.UserData?.PlaybackPositionTicks
@@ -895,6 +1335,66 @@ export async function requestSeries(config: AppConfig, tmdbId: number): Promise<
});
}
export async function requestSeason(config: AppConfig, payload: SeasonRequestPayload): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
}
if (!payload.seriesId || !payload.seasonNumber) {
throw new Error('Series id and season number are required.');
}
await fetchJson(`${config.sonarr.baseUrl}/api/v3/command`, {
method: 'POST',
headers: {
'X-Api-Key': config.sonarr.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'SeasonSearch',
seriesId: payload.seriesId,
seasonNumber: payload.seasonNumber
})
});
}
export async function deleteSeasonFiles(config: AppConfig, payload: SeasonDeletePayload): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
}
if (!payload.seriesId || !payload.seasonNumber) {
throw new Error('Series id and season number are required.');
}
const [episodes, files] = await Promise.all([
getSonarrEpisodesBySeriesId(config, payload.seriesId),
getSonarrEpisodeFilesBySeriesId(config, payload.seriesId)
]);
const fileIds = new Set(
episodes
.filter((episode) => episode.seasonNumber === payload.seasonNumber && episode.episodeFileId)
.map((episode) => Number(episode.episodeFileId))
);
for (const file of files) {
if (file.seasonNumber === payload.seasonNumber && file.id) {
fileIds.add(Number(file.id));
}
}
await Promise.all(
[...fileIds].map((fileId) =>
fetchJson(`${config.sonarr.baseUrl}/api/v3/episodefile/${encodeURIComponent(String(fileId))}`, {
method: 'DELETE',
headers: {
'X-Api-Key': config.sonarr.apiKey
}
})
)
);
}
export async function setEpisodeWatchState(config: AppConfig, payload: EpisodeWatchStateRequest): Promise<void> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey || !config.jellyfin.userId) {
throw new Error('Jellyfin is not configured.');
+25
View File
@@ -109,13 +109,19 @@ export interface MediaCard {
export interface TitleDetailsEpisode {
id: string;
seasonId?: string;
title: string;
overview: string;
subtitle: string;
imageUrl: string;
airDate?: string;
seasonNumber?: number;
episodeNumber?: number;
runtimeMinutes?: number;
availableInJellyfin?: boolean;
availability?: 'available' | 'downloading' | 'missing' | 'unaired';
canDownload?: boolean;
canDelete?: boolean;
watched?: boolean;
progress?: number;
playbackPositionTicks?: number;
@@ -126,12 +132,21 @@ export interface TitleDetailsSeason {
title: string;
seasonNumber?: number;
episodeCount?: number;
availableEpisodeCount?: number;
missingEpisodeCount?: number;
canDownload?: boolean;
canDelete?: boolean;
isDownloading?: boolean;
downloadStatus?: 'idle' | 'processing' | 'downloading';
downloadProgress?: number;
downloadLabel?: string;
}
export interface TitleDetailsResponse {
id: string;
tmdbId?: number;
providerId?: string;
sonarrSeriesId?: number;
playTargetId?: string;
availableInJellyfin: boolean;
title: string;
@@ -183,6 +198,16 @@ export interface EpisodeWatchStateRequest {
watched: boolean;
}
export interface SeasonRequestPayload {
seriesId: number;
seasonNumber: number;
}
export interface SeasonDeletePayload {
seriesId: number;
seasonNumber: number;
}
export interface HubSection {
id: string;
title: string;
+299 -306
View File
@@ -20,6 +20,7 @@ import {
type MediaCard,
type SelectOption,
type ServiceOptionsResponse,
type TitleDetailsEpisode,
type TitleDetailsResponse,
} from '../shared/contracts'
@@ -28,12 +29,9 @@ type EntryStage = 'onboarding' | 'profiles' | 'dashboard'
const MAX_CACHED_HUB_PAGES = 3
type ModalOriginRect = { left: number; top: number; width: number; height: number }
type SelectedCardState = { card: MediaCard; originRect: ModalOriginRect | null }
type VisibleSeasonEpisode = TitleDetailsResponse['episodes'][number] & { seasonId: string }
const SEASON_CACHE_RADIUS = 2
const SEASON_PREFETCH_OFFSETS = [1, -1, 2, -2] as const
const SEASON_PRELOAD_THRESHOLD_RATIO = 0.45
const EPISODE_SCROLL_DISTANCE_RATIO = 0.72
const SEASON_DOMINANCE_SWITCH_RATIO = 0.12
const SEASON_DOMINANCE_SWITCH_MIN_PX = 56
const sidebarItems: Array<{ route: AppRoute; label: string; short: string }> = [
{ route: 'home', label: 'Inicio', short: 'H' },
@@ -408,7 +406,7 @@ function App() {
busy={busy}
hub={currentHub}
loading={hubLoading}
onOpenCard={(card, originRect) => setSelectedCard({ card, originRect })}
onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })}
onRequest={handleRequest}
route={route}
tmdbToken={config.tmdb.bearerToken}
@@ -867,25 +865,11 @@ function resolveHeroSlideStyle(card: MediaCard | undefined) {
return slideImage ? { backgroundImage: `url("${slideImage}")` } : undefined
}
function getSeasonWindowIds(seasons: TitleDetailsResponse['seasons'], centerIndex: number, radius = SEASON_CACHE_RADIUS) {
return seasons
.slice(Math.max(0, centerIndex - radius), centerIndex + radius + 1)
.map((season) => season.id)
}
function getVisibleSeasonEpisodes(
seasons: TitleDetailsResponse['seasons'],
centerIndex: number,
cache: Map<string, TitleDetailsResponse>
): VisibleSeasonEpisode[] {
return seasons
.filter((_, index) => Math.abs(index - centerIndex) <= SEASON_CACHE_RADIUS)
.flatMap((season) =>
(cache.get(season.id)?.episodes || []).map((episode) => ({
...episode,
seasonId: season.id,
}))
)
function getSeasonEpisodes(
episodes: TitleDetailsEpisode[],
seasonId: string
): TitleDetailsEpisode[] {
return episodes.filter((episode) => episode.seasonId === seasonId)
}
function findSeasonAnchorElement(list: HTMLDivElement, seasonId: string) {
@@ -987,11 +971,9 @@ function TitleDetailsModal(props: {
const [details, setDetails] = useState<TitleDetailsResponse | null>(null)
const [detailsLoading, setDetailsLoading] = useState(true)
const [detailsError, setDetailsError] = useState('')
const [requestedSeasonId, setRequestedSeasonId] = useState<string | undefined>(undefined)
const [activeSeasonId, setActiveSeasonId] = useState<string | undefined>(undefined)
const [seasonCacheVersion, setSeasonCacheVersion] = useState(0)
const [loadingSeasonIds, setLoadingSeasonIds] = useState<string[]>([])
const [episodeWatchBusyId, setEpisodeWatchBusyId] = useState<string | null>(null)
const [seasonActionBusy, setSeasonActionBusy] = useState<'download' | 'delete' | null>(null)
const [playerTarget, setPlayerTarget] = useState<{ id: string; title: string } | null>(null)
const [isOverviewExpanded, setIsOverviewExpanded] = useState(false)
const [episodeScrollState, setEpisodeScrollState] = useState({ canScrollLeft: false, canScrollRight: false })
@@ -1002,11 +984,10 @@ function TitleDetailsModal(props: {
const closeTimeoutRef = useRef<number | null>(null)
const modalRef = useRef<HTMLDivElement | null>(null)
const episodeListRef = useRef<HTMLDivElement | null>(null)
const seasonDetailsCacheRef = useRef(new Map<string, TitleDetailsResponse>())
const seasonRequestsRef = useRef(new Map<string, Promise<TitleDetailsResponse>>())
const pendingSeasonScrollRef = useRef<string | null>(null)
const previousRenderedSeasonIdsRef = useRef<string[]>([])
const initialEpisodeFocusKeyRef = useRef('')
const hasUserScrolledEpisodeListRef = useRef(false)
const initialEpisodeListScrollLeftRef = useRef(0)
useLayoutEffect(() => {
if (!props.originRect || !modalRef.current) {
@@ -1026,7 +1007,7 @@ function TitleDetailsModal(props: {
['--title-modal-from-y' as '--title-modal-from-y']: `${translateY}px`,
['--title-modal-from-scale-x' as '--title-modal-from-scale-x']: `${scaleX}`,
['--title-modal-from-scale-y' as '--title-modal-from-scale-y']: `${scaleY}`,
})
} as CSSProperties)
let frameA = 0
let frameB = 0
@@ -1052,115 +1033,44 @@ function TitleDetailsModal(props: {
setDetails(null)
setDetailsLoading(true)
setDetailsError('')
setRequestedSeasonId(undefined)
setActiveSeasonId(undefined)
setSeasonCacheVersion(0)
setLoadingSeasonIds([])
setEpisodeWatchBusyId(null)
setSeasonActionBusy(null)
setPlayerTarget(null)
setIsOverviewExpanded(false)
seasonDetailsCacheRef.current.clear()
seasonRequestsRef.current.clear()
pendingSeasonScrollRef.current = null
previousRenderedSeasonIdsRef.current = []
initialEpisodeFocusKeyRef.current = ''
hasUserScrolledEpisodeListRef.current = false
initialEpisodeListScrollLeftRef.current = 0
}, [props.card.id])
function cacheSeasonDetails(nextDetails: TitleDetailsResponse, requestedSeason?: string) {
const resolvedSeasonId = nextDetails.selectedSeasonId || requestedSeason
if (requestedSeason) {
seasonDetailsCacheRef.current.set(requestedSeason, nextDetails)
}
if (resolvedSeasonId) {
seasonDetailsCacheRef.current.set(resolvedSeasonId, nextDetails)
}
setSeasonCacheVersion((current) => current + 1)
return resolvedSeasonId
}
function ensureSeasonLoaded(seasonId: string) {
const cached = seasonDetailsCacheRef.current.get(seasonId)
if (cached) {
return Promise.resolve(cached)
}
const existingRequest = seasonRequestsRef.current.get(seasonId)
if (existingRequest) {
return existingRequest
}
setLoadingSeasonIds((current) => current.includes(seasonId) ? current : [...current, seasonId])
const request = api.getTitleDetails(props.card, seasonId)
.then((nextDetails) => {
cacheSeasonDetails(nextDetails, seasonId)
return nextDetails
})
.finally(() => {
seasonRequestsRef.current.delete(seasonId)
setLoadingSeasonIds((current) => current.filter((id) => id !== seasonId))
})
seasonRequestsRef.current.set(seasonId, request)
return request
}
function updateEpisodeInCaches(episodeId: string, updater: (episode: NonNullable<TitleDetailsResponse['episodes']>[number]) => NonNullable<TitleDetailsResponse['episodes']>[number]) {
let changed = false
for (const [key, cachedDetails] of seasonDetailsCacheRef.current.entries()) {
if (!cachedDetails.episodes.some((episode) => episode.id === episodeId)) {
continue
}
setDetails((current) => current && current.episodes.some((episode) => episode.id === episodeId)
? {
...current,
episodes: current.episodes.map((episode) => episode.id === episodeId ? updater(episode) : episode)
}
: current)
}
seasonDetailsCacheRef.current.set(key, {
...cachedDetails,
episodes: cachedDetails.episodes.map((episode) => episode.id === episodeId ? updater(episode) : episode)
})
changed = true
}
if (changed) {
setSeasonCacheVersion((current) => current + 1)
setDetails((current) => current && current.episodes.some((episode) => episode.id === episodeId)
? {
...current,
episodes: current.episodes.map((episode) => episode.id === episodeId ? updater(episode) : episode)
}
: current)
}
async function refreshSeasonState(targetSeasonId?: string) {
const refreshSeasonId = targetSeasonId || activeSeasonId || details?.selectedSeasonId
const nextDetails = await api.getTitleDetails(props.card, refreshSeasonId)
setDetails(nextDetails)
setDetailsError('')
setActiveSeasonId(refreshSeasonId || nextDetails.selectedSeasonId)
}
useEffect(() => {
let cancelled = false
const cacheKey = requestedSeasonId || '__default__'
const cachedDetails = requestedSeasonId
? seasonDetailsCacheRef.current.get(requestedSeasonId)
: seasonDetailsCacheRef.current.get(cacheKey)
if (cachedDetails) {
setDetails(cachedDetails)
setDetailsLoading(false)
setDetailsError('')
const resolvedSeasonId = cachedDetails.selectedSeasonId || requestedSeasonId
if (resolvedSeasonId) {
setActiveSeasonId(resolvedSeasonId)
}
return () => {
cancelled = true
}
}
setDetailsLoading(true)
setDetailsError('')
void api.getTitleDetails(props.card, requestedSeasonId)
void api.getTitleDetails(props.card)
.then((nextDetails) => {
if (cancelled) return
seasonDetailsCacheRef.current.set(cacheKey, nextDetails)
const resolvedSeasonId = cacheSeasonDetails(nextDetails, requestedSeasonId)
if (resolvedSeasonId) {
setActiveSeasonId(resolvedSeasonId)
}
setDetails(nextDetails)
setActiveSeasonId(nextDetails.selectedSeasonId)
})
.catch((error) => {
if (cancelled) return
@@ -1174,49 +1084,9 @@ function TitleDetailsModal(props: {
return () => {
cancelled = true
}
}, [props.card, requestedSeasonId])
}, [props.card])
const seasonValue = activeSeasonId ?? requestedSeasonId ?? details?.selectedSeasonId ?? ''
useEffect(() => {
if (!details || details.mediaType !== 'series' || !details.seasons.length) {
return
}
const currentIndex = details.seasons.findIndex((season) => season.id === seasonValue)
if (currentIndex < 0) {
return
}
const keepSeasonIds = new Set(getSeasonWindowIds(details.seasons, currentIndex))
for (const seasonId of Array.from(seasonDetailsCacheRef.current.keys())) {
if (seasonId !== '__default__' && !keepSeasonIds.has(seasonId)) {
seasonDetailsCacheRef.current.delete(seasonId)
}
}
}, [details, seasonCacheVersion, seasonValue])
useEffect(() => {
if (!details || details.mediaType !== 'series' || !details.seasons.length) {
return
}
const activeIndex = details.seasons.findIndex((season) => season.id === seasonValue)
if (activeIndex < 0) {
return
}
const prioritySeasonIds = SEASON_PREFETCH_OFFSETS
.map((offset) => details.seasons[activeIndex + offset]?.id)
.filter(Boolean) as string[]
for (const seasonId of prioritySeasonIds) {
if (!seasonDetailsCacheRef.current.has(seasonId)) {
void ensureSeasonLoaded(seasonId)
}
}
}, [details, seasonValue])
const seasonValue = activeSeasonId ?? details?.selectedSeasonId ?? ''
useEffect(() => {
const list = episodeListRef.current
@@ -1230,14 +1100,12 @@ function TitleDetailsModal(props: {
return
}
const listRect = list.getBoundingClientRect()
const targetRect = targetEpisode.getBoundingClientRect()
list.scrollTo({
left: list.scrollLeft + (targetRect.left - listRect.left),
left: Math.max(0, targetEpisode.offsetLeft - (list.clientWidth - targetEpisode.offsetWidth) / 2),
behavior: 'smooth',
})
pendingSeasonScrollRef.current = null
}, [details, seasonCacheVersion, seasonValue])
}, [details, seasonValue])
useLayoutEffect(() => {
const list = episodeListRef.current
@@ -1255,46 +1123,9 @@ function TitleDetailsModal(props: {
return
}
initialEpisodeListScrollLeftRef.current = list.scrollLeft
initialEpisodeFocusKeyRef.current = focusKey
}, [details?.playTargetId, props.card.id, seasonCacheVersion])
useLayoutEffect(() => {
const list = episodeListRef.current
if (!list || pendingSeasonScrollRef.current) {
return
}
const previousRenderedSeasonIds = previousRenderedSeasonIdsRef.current
const currentRenderedSeasonIds = Array.from(new Set(
Array.from(list.querySelectorAll<HTMLElement>('[data-season-id]'))
.map((card) => card.dataset.seasonId)
.filter((seasonId): seasonId is string => Boolean(seasonId))
))
if (!previousRenderedSeasonIds.length) {
previousRenderedSeasonIdsRef.current = currentRenderedSeasonIds
return
}
const previousFirstSeasonId = previousRenderedSeasonIds[0]
const currentFirstSeasonId = currentRenderedSeasonIds[0]
if (previousFirstSeasonId && currentFirstSeasonId && previousFirstSeasonId !== currentFirstSeasonId) {
if (currentRenderedSeasonIds.includes(previousFirstSeasonId)) {
const anchorCard = findSeasonAnchorElement(list, previousFirstSeasonId)
if (anchorCard) {
list.scrollLeft += anchorCard.offsetLeft
}
} else if (previousRenderedSeasonIds.includes(currentFirstSeasonId)) {
const currentFirstCard = findSeasonAnchorElement(list, currentFirstSeasonId)
if (currentFirstCard) {
list.scrollLeft = Math.max(0, list.scrollLeft - currentFirstCard.offsetLeft)
}
}
}
previousRenderedSeasonIdsRef.current = currentRenderedSeasonIds
}, [details, seasonCacheVersion, seasonValue])
}, [details?.playTargetId, props.card.id])
useEffect(() => {
const list = episodeListRef.current
@@ -1310,6 +1141,13 @@ function TitleDetailsModal(props: {
canScrollRight: maxScrollLeft - list.scrollLeft > 8,
})
if (
!hasUserScrolledEpisodeListRef.current
&& Math.abs(list.scrollLeft - initialEpisodeListScrollLeftRef.current) > 32
) {
hasUserScrolledEpisodeListRef.current = true
}
const viewportLeft = list.scrollLeft
const viewportRight = viewportLeft + list.clientWidth
const coverageBySeason = new Map<string, number>()
@@ -1343,37 +1181,18 @@ function TitleDetailsModal(props: {
}
}
if (dominantSeasonId && dominantSeasonId !== activeSeasonId) {
const currentCoverage = coverageBySeason.get(activeSeasonId || seasonValue) || 0
const coverageThreshold = Math.max(currentCoverage * SEASON_DOMINANCE_SWITCH_RATIO, SEASON_DOMINANCE_SWITCH_MIN_PX)
if (
hasUserScrolledEpisodeListRef.current
&& !pendingSeasonScrollRef.current
&& dominantSeasonId
&& dominantSeasonId !== activeSeasonId
&& dominantCoverage - currentCoverage > coverageThreshold
) {
setActiveSeasonId(dominantSeasonId)
}
const activeBounds = boundsBySeason.get(dominantSeasonId || seasonValue)
const activeIndex = details.seasons.findIndex((season) => season.id === (dominantSeasonId || seasonValue))
if (!activeBounds || activeIndex < 0) {
return
}
const preloadThreshold = list.clientWidth * SEASON_PRELOAD_THRESHOLD_RATIO
const previousSeasonId = details.seasons[activeIndex - 1]?.id
const nextSeasonId = details.seasons[activeIndex + 1]?.id
const nextNextSeasonId = details.seasons[activeIndex + 2]?.id
const previousPreviousSeasonId = details.seasons[activeIndex - 2]?.id
if (previousSeasonId && viewportLeft - activeBounds.left < preloadThreshold && !seasonDetailsCacheRef.current.has(previousSeasonId)) {
void ensureSeasonLoaded(previousSeasonId)
}
if (nextSeasonId && activeBounds.right - viewportRight < preloadThreshold && !seasonDetailsCacheRef.current.has(nextSeasonId)) {
void ensureSeasonLoaded(nextSeasonId)
}
if (nextNextSeasonId && activeBounds.right - viewportRight < preloadThreshold * 0.5 && !seasonDetailsCacheRef.current.has(nextNextSeasonId)) {
void ensureSeasonLoaded(nextNextSeasonId)
}
if (previousPreviousSeasonId && viewportLeft - activeBounds.left < preloadThreshold * 0.5 && !seasonDetailsCacheRef.current.has(previousPreviousSeasonId)) {
void ensureSeasonLoaded(previousPreviousSeasonId)
}
}
syncScrollState()
@@ -1384,7 +1203,7 @@ function TitleDetailsModal(props: {
list.removeEventListener('scroll', syncScrollState)
window.removeEventListener('resize', syncScrollState)
}
}, [activeSeasonId, details, detailsLoading, seasonCacheVersion, seasonValue])
}, [activeSeasonId, details, detailsLoading, seasonValue])
const canRequest = Boolean(props.card.tmdbId && props.card.mediaType !== 'mixed')
const playId = details?.playTargetId || details?.providerId || props.card.providerId
@@ -1404,19 +1223,45 @@ function TitleDetailsModal(props: {
const jellyfinPlayerUrl = playerTarget && props.config.jellyfin.baseUrl
? buildJellyfinPlayerUrl(props.config.jellyfin.baseUrl, playerTarget.id)
: ''
const playLabel = playerTarget?.title || details?.episodes.find((episode) => episode.id === playId)?.title || displayTitle
const activeSeasonMeta = details?.seasons.find((season) => season.id === seasonValue)
const activeSeasonDetails = seasonValue ? seasonDetailsCacheRef.current.get(seasonValue) : null
const activeSeasonDetails = details && seasonValue
? { episodes: getSeasonEpisodes(details.episodes, seasonValue) }
: null
const activeSeasonNumber = activeSeasonMeta?.seasonNumber
const activeSeasonDownloadStatus = activeSeasonMeta?.downloadStatus || 'idle'
const currentSeasonIndex = details?.seasons.findIndex((season) => season.id === seasonValue) ?? -1
const resolvedSeasonIndex = currentSeasonIndex >= 0 ? currentSeasonIndex : 0
const hasPreviousSeason = Boolean(details?.seasons.length && resolvedSeasonIndex > 0)
const hasNextSeason = Boolean(details?.seasons.length && resolvedSeasonIndex < details.seasons.length - 1)
const previousSeasonId = hasPreviousSeason ? details?.seasons[resolvedSeasonIndex - 1]?.id : undefined
const nextSeasonId = hasNextSeason ? details?.seasons[resolvedSeasonIndex + 1]?.id : undefined
const isPreviousSeasonLoading = Boolean(previousSeasonId && loadingSeasonIds.includes(previousSeasonId))
const isNextSeasonLoading = Boolean(nextSeasonId && loadingSeasonIds.includes(nextSeasonId))
const visibleEpisodes = details
? getVisibleSeasonEpisodes(details.seasons, resolvedSeasonIndex, seasonDetailsCacheRef.current)
? details.episodes
: []
const canDownloadSeasonAction = Boolean(
activeSeasonDownloadStatus === 'processing'
|| activeSeasonDownloadStatus === 'downloading'
|| activeSeasonMeta?.canDownload
|| (canRequest && !details?.sonarrSeriesId && typeof activeSeasonNumber === 'number')
)
const canDeleteSeasonAction = Boolean(activeSeasonMeta?.canDelete)
useEffect(() => {
if (!details || details.mediaType !== 'series') {
return
}
if (activeSeasonDownloadStatus !== 'processing' && activeSeasonDownloadStatus !== 'downloading') {
return
}
const timeoutId = window.setTimeout(() => {
void refreshSeasonState(seasonValue || undefined)
}, 3500)
return () => {
window.clearTimeout(timeoutId)
}
}, [activeSeasonDownloadStatus, details, seasonValue])
function handleRequestClose() {
if (!props.originRect || transitionState === 'closing') {
@@ -1452,17 +1297,11 @@ function TitleDetailsModal(props: {
}
pendingSeasonScrollRef.current = nextSeason.id
setActiveSeasonId(nextSeason.id)
startTransition(() => {
setRequestedSeasonId(nextSeason.id)
})
}
function handleSeasonSelectChange(nextSeasonId: string) {
pendingSeasonScrollRef.current = nextSeasonId || null
setActiveSeasonId(nextSeasonId || undefined)
startTransition(() => {
setRequestedSeasonId(nextSeasonId || undefined)
})
}
function handleEpisodeWatchToggle(
@@ -1498,23 +1337,56 @@ function TitleDetailsModal(props: {
})
}
if (playerTarget && jellyfinPlayerUrl) {
return (
<div className="modal-backdrop player-backdrop" role="presentation">
<div className="title-player-view" role="dialog" aria-modal="true">
<div className="title-player-topbar">
<button className="secondary-button" onClick={() => setPlayerTarget(null)} type="button">Back</button>
<button aria-label="Close" className="modal-close omniarr-modal-close" onClick={props.onClose} type="button">×</button>
</div>
<iframe
allow="autoplay; fullscreen; encrypted-media"
className="title-player-frame"
src={jellyfinPlayerUrl}
title={`${playerTarget.title} player`}
/>
</div>
</div>
)
async function handleSeasonDownload() {
if (typeof activeSeasonNumber !== 'number') {
return
}
if (activeSeasonDownloadStatus === 'processing' || activeSeasonDownloadStatus === 'downloading') {
return
}
setSeasonActionBusy('download')
setDetailsError('')
try {
if (details?.sonarrSeriesId) {
await api.requestSeason({
seriesId: details.sonarrSeriesId,
seasonNumber: activeSeasonNumber,
})
} else {
await props.onRequest(props.card)
}
await refreshSeasonState(seasonValue || undefined)
} catch (error) {
setDetailsError(error instanceof Error ? error.message : 'Failed to queue the season download.')
} finally {
setSeasonActionBusy(null)
}
}
async function handleSeasonDelete() {
if (!details?.sonarrSeriesId || typeof activeSeasonNumber !== 'number') {
return
}
if (!window.confirm(`Delete downloaded files for Season ${activeSeasonNumber}?`)) {
return
}
setSeasonActionBusy('delete')
setDetailsError('')
try {
await api.deleteSeason({
seriesId: details.sonarrSeriesId,
seasonNumber: activeSeasonNumber,
})
await refreshSeasonState(seasonValue || undefined)
} catch (error) {
setDetailsError(error instanceof Error ? error.message : 'Failed to delete season files.')
} finally {
setSeasonActionBusy(null)
}
}
return (
@@ -1537,15 +1409,42 @@ function TitleDetailsModal(props: {
<p>Loading details…</p>
</div>
) : null}
<div className="title-details-modal-content">
<div className="title-details-hero" style={backgroundImage ? { backgroundImage: `url("${backgroundImage}")` } : undefined}>
<div className="title-details-darken" />
<button aria-label="Close" className="modal-close omniarr-modal-close" onClick={handleRequestClose} type="button">×</button>
</div>
<div className={playerTarget && jellyfinPlayerUrl ? 'title-details-modal-content has-embedded-player' : 'title-details-modal-content'}>
{playerTarget && jellyfinPlayerUrl ? (
<div className="title-embedded-player">
<div className="title-player-topbar title-player-topbar-embedded">
<button aria-label="Back to details" className="title-player-inline-control" onClick={() => setPlayerTarget(null)} type="button">
<MenuGlyph />
</button>
<div className="title-player-topbar-brand" aria-hidden="true">
{logoUrl ? (
<img alt="" className="title-player-topbar-logo" src={logoUrl} />
) : (
<span className="title-player-topbar-title">{displayTitle}</span>
)}
</div>
<button aria-label="Close" className="title-player-inline-control title-player-inline-close" onClick={handleRequestClose} type="button">×</button>
</div>
<div className="title-player-frame-shell">
<iframe
allow="autoplay; fullscreen; encrypted-media"
className="title-player-frame title-player-frame-embedded"
src={jellyfinPlayerUrl}
title={`${playLabel} player`}
/>
</div>
</div>
) : (
<div className="title-details-hero" style={backgroundImage ? { backgroundImage: `url("${backgroundImage}")` } : undefined}>
<div className="title-details-darken" />
<button aria-label="Close" className="modal-close omniarr-modal-close" onClick={handleRequestClose} type="button">×</button>
</div>
)}
<div className="title-details-body">
<div className={playerTarget && jellyfinPlayerUrl ? 'title-details-body has-embedded-player' : 'title-details-body'}>
<div className="title-details-shell">
{detailsError ? <p className="error-banner">{detailsError}</p> : null}
{playerTarget && jellyfinPlayerUrl ? null : (
<div className="title-details-summary">
<div className="title-details-hero-heading">
{logoUrl ? (
@@ -1572,7 +1471,7 @@ function TitleDetailsModal(props: {
</p>
<div className="title-details-summary-header">
{canPlay ? (
<button className="secondary-button title-action-button title-action-button-play" onClick={() => setPlayerTarget({ id: playId!, title: displayTitle })} type="button">
<button className="secondary-button title-action-button title-action-button-play" onClick={() => setPlayerTarget({ id: playId!, title: playLabel })} type="button">
<PlayGlyph />
<span>Play</span>
</button>
@@ -1586,19 +1485,21 @@ function TitleDetailsModal(props: {
</div>
{displayYear ? <p className="title-details-year">{displayYear}</p> : null}
</div>
)}
{details?.mediaType === 'series' ? (
<div className="title-seasons-panel">
<div className="title-seasons-header">
<div
className="title-season-strip"
>
<button
aria-label="Previous season"
className="title-season-nav"
disabled={!hasPreviousSeason}
onClick={() => changeSeason(-1)}
type="button"
/>
{hasPreviousSeason ? (
<button
aria-label="Previous season"
className="title-season-nav"
onClick={() => changeSeason(-1)}
type="button"
/>
) : null}
{details.seasons.length ? (
<label className="title-season-selector">
<span className="title-season-kicker">Season</span>
@@ -1620,13 +1521,67 @@ function TitleDetailsModal(props: {
<strong>{activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length}</strong>
<span>{(activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length) === 1 ? 'episode' : 'episodes'}</span>
</div>
<button
aria-label="Next season"
className="title-season-nav next"
disabled={!hasNextSeason}
onClick={() => changeSeason(1)}
type="button"
/>
{canDownloadSeasonAction || canDeleteSeasonAction ? (
<div className="title-season-actions">
{canDownloadSeasonAction || activeSeasonMeta?.isDownloading ? <div className="title-season-divider title-season-action-divider" aria-hidden="true" /> : null}
{canDownloadSeasonAction || activeSeasonMeta?.isDownloading ? (
activeSeasonDownloadStatus === 'downloading' ? (
<div
aria-label="Season downloading"
className="title-season-progress"
role="img"
title={activeSeasonMeta?.downloadLabel || 'Downloading'}
>
<div
className="title-season-progress-bar"
style={{ width: `${activeSeasonMeta?.downloadProgress || 0}%` }}
/>
</div>
) : activeSeasonDownloadStatus === 'processing' ? (
<div
aria-label="Season processing"
className="title-season-processing"
role="img"
title={activeSeasonMeta?.downloadLabel || 'Processing'}
>
<span className="title-season-processing-spinner" aria-hidden="true" />
</div>
) : (
<button
aria-label="Download season"
className="title-season-action"
disabled={seasonActionBusy === 'download'}
onClick={() => void handleSeasonDownload()}
type="button"
>
<DownloadGlyph />
</button>
)
) : null}
{canDeleteSeasonAction ? (
<div className="title-season-divider title-season-action-divider" aria-hidden="true" />
) : null}
{canDeleteSeasonAction ? (
<button
aria-label="Delete season files"
className="title-season-action title-season-action-delete"
disabled={seasonActionBusy === 'delete'}
onClick={() => void handleSeasonDelete()}
type="button"
>
<TrashGlyph />
</button>
) : null}
</div>
) : null}
{hasNextSeason ? (
<button
aria-label="Next season"
className="title-season-nav next"
onClick={() => changeSeason(1)}
type="button"
/>
) : null}
</div>
</div>
<div
@@ -1652,8 +1607,6 @@ function TitleDetailsModal(props: {
type="button"
/>
) : null}
{isPreviousSeasonLoading ? <div className="episode-dock-spinner left" aria-hidden="true" /> : null}
{isNextSeasonLoading ? <div className="episode-dock-spinner right" aria-hidden="true" /> : null}
<div
className="episode-list"
ref={episodeListRef}
@@ -1663,20 +1616,33 @@ function TitleDetailsModal(props: {
className={[
'episode-card',
episode.id === details.playTargetId ? 'is-next-up' : '',
episode.id === playerTarget?.id ? 'is-playing' : '',
episode.id === details.playTargetId && episode.id === playerTarget?.id ? 'is-playing-next-up' : '',
episode.progress && !episode.watched ? 'has-progress' : '',
episode.canDownload ? 'is-downloadable' : '',
episode.availability === 'downloading' ? 'is-downloading' : '',
!episode.availableInJellyfin ? 'is-unavailable' : '',
].filter(Boolean).join(' ')}
data-episode-id={episode.id}
data-season-id={episode.seasonId}
key={`${episode.seasonId}-${episode.id}`}
onClick={() => setPlayerTarget({ id: episode.id, title: episode.title })}
onClick={() => {
if (!episode.availableInJellyfin) {
return
}
setPlayerTarget({ id: episode.id, title: episode.title })
}}
onKeyDown={(event) => {
if (!episode.availableInJellyfin) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setPlayerTarget({ id: episode.id, title: episode.title })
}
}}
role="button"
tabIndex={0}
role={episode.availableInJellyfin ? 'button' : undefined}
tabIndex={episode.availableInJellyfin ? 0 : -1}
>
<div className="episode-card-art">
{episode.progress && !episode.watched ? (
@@ -1691,21 +1657,32 @@ function TitleDetailsModal(props: {
<div className="episode-card-title-row">
<strong>{episode.title}</strong>
<div className="episode-card-state-icons">
<button
aria-label={episode.watched ? 'Mark episode as unwatched' : 'Mark episode as watched'}
className={episode.watched ? 'episode-card-watch-toggle is-watched' : 'episode-card-watch-toggle'}
disabled={episodeWatchBusyId === episode.id}
onClick={(event) => handleEpisodeWatchToggle(
event,
episode.id,
episode.watched,
episode.progress,
episode.playbackPositionTicks
)}
type="button"
>
<CheckGlyph />
</button>
{episode.canDownload || episode.availability === 'downloading' ? (
<span
aria-label={episode.availability === 'downloading' ? 'Episode downloading' : 'Episode available to download'}
className={episode.availability === 'downloading' ? 'episode-card-availability is-downloading' : 'episode-card-availability'}
title={episode.availability === 'downloading' ? 'Downloading' : 'Available to download'}
>
<DownloadGlyph />
</span>
) : null}
{episode.availableInJellyfin ? (
<button
aria-label={episode.watched ? 'Mark episode as unwatched' : 'Mark episode as watched'}
className={episode.watched ? 'episode-card-watch-toggle is-watched' : 'episode-card-watch-toggle'}
disabled={episodeWatchBusyId === episode.id}
onClick={(event) => handleEpisodeWatchToggle(
event,
episode.id,
episode.watched,
episode.progress,
episode.playbackPositionTicks
)}
type="button"
>
<CheckGlyph />
</button>
) : null}
</div>
</div>
<span>{formatEpisodeMeta(episode.subtitle)}</span>
@@ -1746,6 +1723,14 @@ function PlayGlyph() {
)
}
function MenuGlyph() {
return (
<svg aria-hidden="true" className="title-action-icon" viewBox="0 0 16 16">
<path d="M3 4.25h10v1.5H3zM3 7.25h10v1.5H3zM3 10.25h10v1.5H3z" fill="currentColor" />
</svg>
)
}
function DownloadGlyph() {
return (
<svg aria-hidden="true" className="title-action-icon" viewBox="0 0 16 16">
@@ -1755,6 +1740,14 @@ function DownloadGlyph() {
)
}
function TrashGlyph() {
return (
<svg aria-hidden="true" className="title-action-icon" viewBox="0 0 16 16">
<path d="M5.5 3.25h5l.55 1.25H13v1.25h-1v6.5A1.75 1.75 0 0 1 10.25 14h-4.5A1.75 1.75 0 0 1 4 12.25v-6.5H3V4.5h1.95l.55-1.25Zm-.25 2.5v6.5c0 .28.22.5.5.5h4.5c.28 0 .5-.22.5-.5v-6.5h-5.5ZM6.75 7h1.25v4h-1.25V7Zm2.25 0h1.25v4H9V7Z" fill="currentColor" />
</svg>
)
}
function CheckGlyph() {
return (
<svg aria-hidden="true" viewBox="0 0 16 16">
+12
View File
@@ -9,6 +9,8 @@ import type {
ServiceOptionsResponse,
SessionResponse,
EpisodeWatchStateRequest,
SeasonDeletePayload,
SeasonRequestPayload,
TitleDetailsRequest,
TitleDetailsResponse
} from '../shared/contracts'
@@ -105,4 +107,14 @@ export const api = {
method: 'POST',
body: JSON.stringify({ tmdbId }),
}),
requestSeason: (payload: SeasonRequestPayload) =>
request<{ ok: boolean }>('/api/requests/season', {
method: 'POST',
body: JSON.stringify(payload),
}),
deleteSeason: (payload: SeasonDeletePayload) =>
request<{ ok: boolean }>('/api/seasons/delete', {
method: 'POST',
body: JSON.stringify(payload),
}),
}
+337 -19
View File
@@ -959,21 +959,84 @@ img {
right: 1.5rem;
}
.title-player-view {
position: fixed;
inset: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background: #05070b;
}
.title-player-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem 0;
}
.title-player-topbar-embedded {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: 2.35rem minmax(0, 1fr) 2.35rem;
align-items: center;
gap: 1rem;
padding: 0 0 0.9rem;
}
.title-player-topbar-brand {
min-width: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
}
.title-player-topbar-logo {
display: block;
width: auto;
max-width: min(14rem, 42vw);
max-height: 2rem;
object-fit: contain;
object-position: center;
}
.title-player-topbar-title {
display: block;
max-width: min(14rem, 42vw);
overflow: hidden;
color: rgba(238, 244, 255, 0.9);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-overflow: ellipsis;
white-space: nowrap;
}
.title-player-inline-control {
display: grid;
place-items: center;
width: 2.35rem;
height: 2.35rem;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(6, 8, 14, 0.72);
color: #eef4ff;
transition: background-color 160ms ease, transform 160ms ease;
}
.title-player-inline-control:hover,
.title-player-inline-control:focus-visible {
background: rgba(14, 18, 28, 0.88);
transform: translateY(-1px);
}
.title-player-inline-control .title-action-icon {
width: 1.05rem;
height: 1.05rem;
}
.title-player-inline-close {
font-size: 1.45rem;
line-height: 1;
text-indent: 0.06rem;
padding-bottom: 0.06rem;
}
.title-player-frame {
width: 100%;
height: 100%;
@@ -981,10 +1044,27 @@ img {
background: #000;
}
.player-backdrop {
padding: 0;
.title-embedded-player {
position: relative;
margin-top: 1.6rem;
padding: 0 1.5rem;
}
.title-player-frame-shell {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 1.3rem;
overflow: hidden;
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.34);
background: #000;
backdrop-filter: none;
}
.title-player-frame-embedded {
display: block;
width: 100%;
height: 100%;
min-height: 0;
border: 0;
}
.title-details-modal {
@@ -1051,6 +1131,13 @@ img {
height: 100%;
}
.title-details-modal-content.has-embedded-player {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow-y: auto;
overflow-x: hidden;
}
.title-details-modal-transition .title-details-modal-content {
opacity: 0;
transition: opacity 220ms ease;
@@ -1194,6 +1281,18 @@ img {
background: linear-gradient(180deg, rgba(9, 11, 16, 0) 0%, rgba(9, 11, 16, 0.015) 12%, rgba(9, 11, 16, 0.04) 28%, rgba(9, 11, 16, 0.08) 44%, rgba(9, 11, 16, 0.16) 60%, rgba(9, 11, 16, 0.28) 74%, rgba(9, 11, 16, 0.46) 88%, #090b10 100%);
}
.title-details-body.has-embedded-player {
min-height: 0;
align-items: stretch;
overflow: visible;
padding-top: 0;
background: #090b10;
}
.title-details-body.has-embedded-player::before {
display: none;
}
.title-details-body::before {
content: "";
position: absolute;
@@ -1215,6 +1314,12 @@ img {
padding-top: 12rem;
}
.title-details-body.has-embedded-player .title-details-shell {
align-content: start;
min-height: 0;
padding-top: 1rem;
}
.title-details-summary {
position: relative;
display: grid;
@@ -1271,6 +1376,10 @@ img {
gap: 1.3rem;
}
.title-details-body.has-embedded-player .title-seasons-panel {
align-content: start;
}
.title-seasons-header {
padding: 0.35rem 0 0.15rem;
}
@@ -1278,6 +1387,7 @@ img {
.title-season-strip {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.8rem;
min-height: 0;
padding: 0;
@@ -1401,6 +1511,95 @@ img {
text-transform: uppercase;
}
.title-season-actions {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.title-season-action-divider {
margin-left: 0.16rem;
margin-right: 0.16rem;
}
.title-season-action {
display: inline-grid;
place-items: center;
min-width: 1.9rem;
min-height: 1.9rem;
padding: 0.18rem;
border: 0;
border-radius: 0;
background: transparent;
color: rgba(237, 242, 255, 0.82);
transition: color 180ms ease, transform 180ms ease, opacity 180ms ease;
}
.title-season-action .title-action-icon {
width: 1.2rem;
height: 1.2rem;
}
.title-season-action:hover,
.title-season-action:focus-visible {
color: #ffffff;
transform: translateY(-1px);
}
.title-season-action.is-danger {
color: #ffdbdb;
}
.title-season-action-delete {
color: rgba(237, 242, 255, 0.72);
}
.title-season-action-delete:hover,
.title-season-action-delete:focus-visible {
color: rgba(244, 247, 255, 0.92);
}
.title-season-processing,
.title-season-progress {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.2rem;
min-height: 1.9rem;
}
.title-season-processing-spinner {
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: rgba(255, 255, 255, 0.88);
border-radius: 999px;
animation: episodeDockSpinner 720ms linear infinite;
}
.title-season-progress {
width: 2.5rem;
}
.title-season-progress::before {
content: "";
position: absolute;
left: 0;
right: 0;
height: 0.28rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.title-season-progress-bar {
position: absolute;
left: 0;
height: 0.28rem;
border-radius: 999px;
background: #e50914;
}
.episode-dock {
position: relative;
border-radius: 2rem;
@@ -1458,14 +1657,18 @@ img {
flex-shrink: 0;
padding: 0;
border: 0;
border-radius: 1rem;
background: transparent;
color: inherit;
text-align: left;
overflow: visible;
cursor: pointer;
transition: transform 220ms ease, opacity 220ms ease;
}
.episode-card.is-unavailable {
cursor: default;
}
.episode-dock-arrow {
position: absolute;
top: 50%;
@@ -1545,6 +1748,11 @@ img {
transform: translateY(-0.22rem);
}
.episode-card.is-unavailable:hover,
.episode-card.is-unavailable:focus-visible {
transform: none;
}
.episode-card-art {
position: relative;
overflow: hidden;
@@ -1562,22 +1770,66 @@ img {
inset: 0;
z-index: 3;
border-radius: inherit;
padding: 2px;
background: linear-gradient(90deg, #68abff, #ffd166);
opacity: 0;
padding: var(--episode-border-width, 2px);
background: var(--episode-border-gradient, linear-gradient(90deg, #68abff, #ffd166));
opacity: var(--episode-border-opacity, 0);
pointer-events: none;
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
box-shadow: var(--episode-border-shadow, none);
transition: padding 220ms ease, opacity 220ms ease, background 220ms ease, box-shadow 220ms ease;
}
.episode-card.is-next-up .episode-card-art::before {
opacity: 1;
.episode-card.is-next-up {
--episode-border-opacity: 1;
--episode-border-width: 2px;
--episode-border-gradient: linear-gradient(90deg, #68abff, #ffd166);
}
.episode-card.is-next-up.has-progress .episode-card-art::before {
.episode-card.is-playing {
--episode-border-opacity: 1;
--episode-border-width: 4px;
--episode-border-gradient: linear-gradient(90deg, #79b8ff, #9f8cff);
--episode-border-shadow: 0 0 1.15rem rgba(121, 184, 255, 0.22);
}
.episode-card.is-playing-next-up .episode-card-art::before {
animation: playingNextUpBorderPulse 220ms ease-out 1 both;
}
.episode-card.is-playing-next-up.has-progress::after {
animation: playingNextUpBorderJoinPulse 220ms ease-out 1 both;
}
.episode-card.is-next-up.has-progress::after,
.episode-card.is-playing.has-progress::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
z-index: 3;
width: 7%;
min-width: 1.05rem;
height: 2px;
border-bottom-left-radius: 999px;
background: currentColor;
pointer-events: none;
}
.episode-card.is-next-up.has-progress::after {
color: #68abff;
}
.episode-card.is-playing.has-progress::after {
color: #79b8ff;
height: 4px;
}
.episode-card.is-next-up.has-progress .episode-card-art::before,
.episode-card.is-playing.has-progress .episode-card-art::before {
inset: 0;
padding: 2px 2px 0;
padding: var(--episode-border-width, 2px) var(--episode-border-width, 2px) 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
@@ -1586,6 +1838,37 @@ img {
box-shadow: none;
}
@keyframes playingNextUpBorderPulse {
0% {
padding: 2px;
background: linear-gradient(90deg, #68abff, #ffd166);
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
}
100% {
padding: 4px;
background: linear-gradient(90deg, #79b8ff, #9f8cff);
box-shadow: 0 0 1.15rem rgba(121, 184, 255, 0.22);
}
}
@keyframes playingNextUpBorderJoinPulse {
0% {
background: #68abff;
height: 2px;
}
100% {
background: #79b8ff;
height: 4px;
}
}
.episode-image-shell {
width: 100%;
height: 100%;
}
.episode-card-art img {
width: 100%;
height: 100%;
@@ -1636,6 +1919,24 @@ img {
flex: 0 0 auto;
}
.episode-card-availability {
display: inline-grid;
place-items: center;
width: 1.55rem;
height: 1.55rem;
color: rgba(255, 255, 255, 0.78);
opacity: 0.88;
}
.episode-card-availability .title-action-icon {
width: 0.98rem;
height: 0.98rem;
}
.episode-card-availability.is-downloading {
animation: episodeCardAvailabilityPulse 900ms ease-in-out infinite alternate;
}
.episode-card-watch-toggle svg {
width: 1rem;
height: 1rem;
@@ -1729,6 +2030,23 @@ img {
font-size: 0.83rem;
}
.episode-card.is-downloadable .episode-card-copy strong,
.episode-card.is-downloading .episode-card-copy strong {
color: #f4f7ff;
}
@keyframes episodeCardAvailabilityPulse {
from {
opacity: 0.5;
transform: translateY(0);
}
to {
opacity: 1;
transform: translateY(-1px);
}
}
.title-modal-grid {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);