mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
feature: add season download and request functionality
This commit is contained in:
1 parent
98db35d367
commit
55956bc52d
6 files changed
+1227
-353
No files matched your search
+27
-1
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user