mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
feature: add episode progress indicator
This commit is contained in:
1 parent
0cfde11d53
commit
98db35d367
6 files changed
+473
-80
No files matched your search
+10
-1
@@ -1,7 +1,7 @@
|
||||
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 TitleDetailsRequest } from '../shared/contracts.js';
|
||||
import { defaultConfig, type AppConfig, type AuthPayload, type EpisodeWatchStateRequest, 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 {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
removeDownload,
|
||||
requestMovie,
|
||||
requestSeries,
|
||||
setEpisodeWatchState,
|
||||
sanitizeConfig
|
||||
} from './lib/services.js';
|
||||
|
||||
@@ -224,6 +225,14 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/episodes/watch-state' && method === 'POST') {
|
||||
const config = await readConfig(session!.encryptionKey);
|
||||
const body = await readJson<EpisodeWatchStateRequest>(request);
|
||||
await setEpisodeWatchState(config, body);
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/downloads/delete' && method === 'POST') {
|
||||
const config = await readConfig(session!.encryptionKey);
|
||||
const body = await readJson<{ id: string }>(request);
|
||||
|
||||
+101
-47
@@ -11,7 +11,8 @@ import type {
|
||||
TitleDetailsEpisode,
|
||||
TitleDetailsRequest,
|
||||
TitleDetailsResponse,
|
||||
TitleDetailsSeason
|
||||
TitleDetailsSeason,
|
||||
EpisodeWatchStateRequest
|
||||
} from '../../shared/contracts.js';
|
||||
|
||||
const TMDB_IMAGE = 'https://image.tmdb.org/t/p';
|
||||
@@ -406,17 +407,21 @@ export async function getTitleDetails(
|
||||
}
|
||||
|
||||
const seasons = await getSeriesSeasons(config, seriesItem.Id);
|
||||
const selectedSeason = chooseSelectedSeason(seasons, seasonId, item);
|
||||
const nextEpisode = await getNextEpisodeToPlay(config, seriesItem.Id);
|
||||
const selectedSeason = chooseSelectedSeason(seasons, seasonId, item, nextEpisode);
|
||||
const episodes = selectedSeason?.id
|
||||
? await getSeasonEpisodes(config, selectedSeason.id)
|
||||
? await getSeasonEpisodes(config, seriesItem.Id, selectedSeason.id)
|
||||
: [];
|
||||
const nextEpisode = await getNextEpisodeToPlay(config, seriesItem.Id, episodes);
|
||||
|
||||
const resolvedNextEpisode = nextEpisode && episodes.some((episode) => episode.id === nextEpisode.id)
|
||||
? episodes.find((episode) => episode.id === nextEpisode.id) || nextEpisode
|
||||
: nextEpisode;
|
||||
|
||||
return {
|
||||
id: card.id,
|
||||
tmdbId,
|
||||
providerId: seriesItem.Id,
|
||||
playTargetId: nextEpisode?.id || seriesItem.Id,
|
||||
playTargetId: resolvedNextEpisode?.id || seriesItem.Id,
|
||||
availableInJellyfin: Boolean(seriesItem.Id),
|
||||
title,
|
||||
overview,
|
||||
@@ -714,7 +719,8 @@ async function getSeriesSeasons(config: AppConfig, seriesId: string): Promise<Ti
|
||||
function chooseSelectedSeason(
|
||||
seasons: TitleDetailsSeason[],
|
||||
requestedSeasonId: string | undefined,
|
||||
item: JellyfinItem | null
|
||||
item: JellyfinItem | null,
|
||||
nextEpisode?: TitleDetailsEpisode | null
|
||||
): TitleDetailsSeason | undefined {
|
||||
if (requestedSeasonId) {
|
||||
const requestedSeason = seasons.find((season) => season.id === requestedSeasonId);
|
||||
@@ -723,6 +729,13 @@ function chooseSelectedSeason(
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof nextEpisode?.seasonNumber === 'number') {
|
||||
const matchingSeason = seasons.find((season) => season.seasonNumber === nextEpisode.seasonNumber);
|
||||
if (matchingSeason) {
|
||||
return matchingSeason;
|
||||
}
|
||||
}
|
||||
|
||||
if (item?.Type === 'Episode' && typeof item.ParentIndexNumber === 'number') {
|
||||
const matchingSeason = seasons.find((season) => season.seasonNumber === item.ParentIndexNumber);
|
||||
if (matchingSeason) {
|
||||
@@ -733,32 +746,17 @@ function chooseSelectedSeason(
|
||||
return seasons.find((season) => season.seasonNumber && season.seasonNumber > 0) || seasons[0];
|
||||
}
|
||||
|
||||
async function getSeasonEpisodes(config: AppConfig, seasonId: string): Promise<TitleDetailsEpisode[]> {
|
||||
async function getSeasonEpisodes(config: AppConfig, seriesId: string, seasonId: string): Promise<TitleDetailsEpisode[]> {
|
||||
try {
|
||||
const episodes = await jellyfinFetch<JellyfinItem[]>(
|
||||
config,
|
||||
`/Users/${encodeURIComponent(config.jellyfin.userId)}/Items?parentId=${encodeURIComponent(seasonId)}&includeItemTypes=Episode&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber&sortBy=SortName`
|
||||
`/Shows/${encodeURIComponent(seriesId)}/Episodes?userId=${encodeURIComponent(config.jellyfin.userId)}&seasonId=${encodeURIComponent(seasonId)}&enableUserData=true&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber&sortBy=SortName`
|
||||
);
|
||||
|
||||
return episodes
|
||||
.filter((episode) => episode.Id)
|
||||
.sort((left, right) => (left.IndexNumber ?? 0) - (right.IndexNumber ?? 0))
|
||||
.map((episode) => {
|
||||
const imageTag = episode.ImageTags?.Primary || '';
|
||||
return {
|
||||
id: String(episode.Id),
|
||||
title: episode.Name || `Episode ${episode.IndexNumber ?? ''}`.trim(),
|
||||
overview: episode.Overview || '',
|
||||
subtitle: [
|
||||
typeof episode.ParentIndexNumber === 'number' ? `Season ${episode.ParentIndexNumber}` : '',
|
||||
typeof episode.IndexNumber === 'number' ? `Episode ${episode.IndexNumber}` : ''
|
||||
].filter(Boolean).join(', '),
|
||||
imageUrl: buildJellyfinAssetUrl(String(episode.Id), 'Primary', 960, imageTag),
|
||||
seasonNumber: episode.ParentIndexNumber,
|
||||
episodeNumber: episode.IndexNumber,
|
||||
runtimeMinutes: episode.RunTimeTicks ? Math.max(1, Math.round(episode.RunTimeTicks / 600000000)) : undefined
|
||||
};
|
||||
});
|
||||
.map(mapJellyfinEpisode);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -766,40 +764,71 @@ async function getSeasonEpisodes(config: AppConfig, seasonId: string): Promise<T
|
||||
|
||||
async function getNextEpisodeToPlay(
|
||||
config: AppConfig,
|
||||
seriesId: string,
|
||||
currentEpisodes: TitleDetailsEpisode[]
|
||||
seriesId: string
|
||||
): Promise<TitleDetailsEpisode | null> {
|
||||
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey || !config.jellyfin.userId) {
|
||||
return currentEpisodes[0] || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await jellyfinFetch<{ Items?: JellyfinItem[] }>(
|
||||
config,
|
||||
`/Shows/NextUp?userId=${encodeURIComponent(config.jellyfin.userId)}&seriesId=${encodeURIComponent(seriesId)}&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber&limit=1`
|
||||
`/Shows/NextUp?userId=${encodeURIComponent(config.jellyfin.userId)}&seriesId=${encodeURIComponent(seriesId)}&enableUserData=true&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber&limit=1`
|
||||
);
|
||||
const episode = response.Items?.[0];
|
||||
if (!episode?.Id) {
|
||||
return currentEpisodes[0] || null;
|
||||
const episode = response.Items?.find((item) => item.Id);
|
||||
if (episode) {
|
||||
return mapJellyfinEpisode(episode);
|
||||
}
|
||||
|
||||
const imageTag = episode.ImageTags?.Primary || '';
|
||||
return {
|
||||
id: String(episode.Id),
|
||||
title: episode.Name || `Episode ${episode.IndexNumber ?? ''}`.trim(),
|
||||
overview: episode.Overview || '',
|
||||
subtitle: [
|
||||
typeof episode.ParentIndexNumber === 'number' ? `Season ${episode.ParentIndexNumber}` : '',
|
||||
typeof episode.IndexNumber === 'number' ? `Episode ${episode.IndexNumber}` : ''
|
||||
].filter(Boolean).join(', '),
|
||||
imageUrl: buildJellyfinAssetUrl(String(episode.Id), 'Primary', 960, imageTag),
|
||||
seasonNumber: episode.ParentIndexNumber,
|
||||
episodeNumber: episode.IndexNumber,
|
||||
runtimeMinutes: episode.RunTimeTicks ? Math.max(1, Math.round(episode.RunTimeTicks / 600000000)) : undefined
|
||||
};
|
||||
} catch {
|
||||
return currentEpisodes[0] || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const episodes = await getSeriesEpisodesForNextUp(config, seriesId);
|
||||
const nextEpisode = episodes.find((episode) => !episode.watched);
|
||||
return nextEpisode || episodes[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeriesEpisodesForNextUp(config: AppConfig, seriesId: string): Promise<TitleDetailsEpisode[]> {
|
||||
const episodes = await jellyfinFetch<JellyfinItem[]>(
|
||||
config,
|
||||
`/Users/${encodeURIComponent(config.jellyfin.userId)}/Items?parentId=${encodeURIComponent(seriesId)}&recursive=true&includeItemTypes=Episode&enableUserData=true&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber&sortBy=SortName`
|
||||
);
|
||||
|
||||
return episodes
|
||||
.filter((episode) => episode.Id)
|
||||
.sort((left, right) => {
|
||||
const seasonDifference = (left.ParentIndexNumber ?? 0) - (right.ParentIndexNumber ?? 0);
|
||||
if (seasonDifference !== 0) {
|
||||
return seasonDifference;
|
||||
}
|
||||
|
||||
return (left.IndexNumber ?? 0) - (right.IndexNumber ?? 0);
|
||||
})
|
||||
.map(mapJellyfinEpisode);
|
||||
}
|
||||
|
||||
function mapJellyfinEpisode(episode: JellyfinItem): TitleDetailsEpisode {
|
||||
const imageTag = episode.ImageTags?.Primary || '';
|
||||
|
||||
return {
|
||||
id: String(episode.Id),
|
||||
title: episode.Name || `Episode ${episode.IndexNumber ?? ''}`.trim(),
|
||||
overview: episode.Overview || '',
|
||||
subtitle: [
|
||||
typeof episode.ParentIndexNumber === 'number' ? `Season ${episode.ParentIndexNumber}` : '',
|
||||
typeof episode.IndexNumber === 'number' ? `Episode ${episode.IndexNumber}` : ''
|
||||
].filter(Boolean).join(', '),
|
||||
imageUrl: buildJellyfinAssetUrl(String(episode.Id), 'Primary', 960, imageTag),
|
||||
seasonNumber: episode.ParentIndexNumber,
|
||||
episodeNumber: episode.IndexNumber,
|
||||
runtimeMinutes: episode.RunTimeTicks ? Math.max(1, Math.round(episode.RunTimeTicks / 600000000)) : undefined,
|
||||
watched: Boolean(episode.UserData?.Played),
|
||||
progress: Math.max(0, Math.min(100, Math.round(episode.UserData?.PlayedPercentage || 0))) || undefined,
|
||||
playbackPositionTicks: episode.UserData?.PlaybackPositionTicks
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestMovie(config: AppConfig, tmdbId: number): Promise<void> {
|
||||
@@ -866,6 +895,31 @@ export async function requestSeries(config: AppConfig, tmdbId: number): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
const episodeId = String(payload.episodeId || '')
|
||||
if (!episodeId) {
|
||||
throw new Error('Episode id is required.')
|
||||
}
|
||||
|
||||
const path = `/Users/${encodeURIComponent(config.jellyfin.userId)}/PlayedItems/${encodeURIComponent(episodeId)}`
|
||||
const url = `${config.jellyfin.baseUrl}${path}`
|
||||
const response = await fetch(payload.watched ? `${url}?datePlayed=${encodeURIComponent(new Date().toISOString())}` : url, {
|
||||
method: payload.watched ? 'POST' : 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Emby-Token': config.jellyfin.apiKey
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function proxyJellyfinImage(
|
||||
config: AppConfig,
|
||||
itemId: string,
|
||||
|
||||
@@ -116,6 +116,9 @@ export interface TitleDetailsEpisode {
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
runtimeMinutes?: number;
|
||||
watched?: boolean;
|
||||
progress?: number;
|
||||
playbackPositionTicks?: number;
|
||||
}
|
||||
|
||||
export interface TitleDetailsSeason {
|
||||
@@ -175,6 +178,11 @@ export interface TitleDetailsRequest {
|
||||
seasonId?: string;
|
||||
}
|
||||
|
||||
export interface EpisodeWatchStateRequest {
|
||||
episodeId: string;
|
||||
watched: boolean;
|
||||
}
|
||||
|
||||
export interface HubSection {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
+192
-31
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
startTransition,
|
||||
useEffect,
|
||||
@@ -27,6 +28,12 @@ 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 sidebarItems: Array<{ route: AppRoute; label: string; short: string }> = [
|
||||
{ route: 'home', label: 'Inicio', short: 'H' },
|
||||
@@ -860,6 +867,46 @@ 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 findSeasonAnchorElement(list: HTMLDivElement, seasonId: string) {
|
||||
return list.querySelector<HTMLElement>(`[data-season-id="${CSS.escape(seasonId)}"]`)
|
||||
}
|
||||
|
||||
function findEpisodeElement(list: HTMLDivElement, episodeId: string) {
|
||||
return list.querySelector<HTMLElement>(`[data-episode-id="${CSS.escape(episodeId)}"]`)
|
||||
}
|
||||
|
||||
function centerEpisodeInList(list: HTMLDivElement, episodeId: string, behavior: ScrollBehavior) {
|
||||
const targetEpisode = findEpisodeElement(list, episodeId)
|
||||
if (!targetEpisode) {
|
||||
return false
|
||||
}
|
||||
|
||||
const centeredLeft = Math.max(0, targetEpisode.offsetLeft - (list.clientWidth - targetEpisode.offsetWidth) / 2)
|
||||
list.scrollTo({ left: centeredLeft, behavior })
|
||||
return true
|
||||
}
|
||||
|
||||
function cacheHubPage(
|
||||
current: Partial<Record<HubPageResponse['page'], HubPageResponse>>,
|
||||
page: HubPageResponse['page'],
|
||||
@@ -944,6 +991,7 @@ function TitleDetailsModal(props: {
|
||||
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 [playerTarget, setPlayerTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
const [isOverviewExpanded, setIsOverviewExpanded] = useState(false)
|
||||
const [episodeScrollState, setEpisodeScrollState] = useState({ canScrollLeft: false, canScrollRight: false })
|
||||
@@ -958,6 +1006,7 @@ function TitleDetailsModal(props: {
|
||||
const seasonRequestsRef = useRef(new Map<string, Promise<TitleDetailsResponse>>())
|
||||
const pendingSeasonScrollRef = useRef<string | null>(null)
|
||||
const previousRenderedSeasonIdsRef = useRef<string[]>([])
|
||||
const initialEpisodeFocusKeyRef = useRef('')
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!props.originRect || !modalRef.current) {
|
||||
@@ -1007,12 +1056,14 @@ function TitleDetailsModal(props: {
|
||||
setActiveSeasonId(undefined)
|
||||
setSeasonCacheVersion(0)
|
||||
setLoadingSeasonIds([])
|
||||
setEpisodeWatchBusyId(null)
|
||||
setPlayerTarget(null)
|
||||
setIsOverviewExpanded(false)
|
||||
seasonDetailsCacheRef.current.clear()
|
||||
seasonRequestsRef.current.clear()
|
||||
pendingSeasonScrollRef.current = null
|
||||
previousRenderedSeasonIdsRef.current = []
|
||||
initialEpisodeFocusKeyRef.current = ''
|
||||
}, [props.card.id])
|
||||
|
||||
function cacheSeasonDetails(nextDetails: TitleDetailsResponse, requestedSeason?: string) {
|
||||
@@ -1053,6 +1104,31 @@ function TitleDetailsModal(props: {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const cacheKey = requestedSeasonId || '__default__'
|
||||
@@ -1112,11 +1188,7 @@ function TitleDetailsModal(props: {
|
||||
return
|
||||
}
|
||||
|
||||
const keepSeasonIds = new Set(
|
||||
details.seasons
|
||||
.slice(Math.max(0, currentIndex - 2), currentIndex + 3)
|
||||
.map((season) => season.id)
|
||||
)
|
||||
const keepSeasonIds = new Set(getSeasonWindowIds(details.seasons, currentIndex))
|
||||
|
||||
for (const seasonId of Array.from(seasonDetailsCacheRef.current.keys())) {
|
||||
if (seasonId !== '__default__' && !keepSeasonIds.has(seasonId)) {
|
||||
@@ -1135,12 +1207,9 @@ function TitleDetailsModal(props: {
|
||||
return
|
||||
}
|
||||
|
||||
const prioritySeasonIds = [
|
||||
details.seasons[activeIndex + 1]?.id,
|
||||
details.seasons[activeIndex - 1]?.id,
|
||||
details.seasons[activeIndex + 2]?.id,
|
||||
details.seasons[activeIndex - 2]?.id,
|
||||
].filter(Boolean) as string[]
|
||||
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)) {
|
||||
@@ -1156,7 +1225,7 @@ function TitleDetailsModal(props: {
|
||||
return
|
||||
}
|
||||
|
||||
const targetEpisode = list.querySelector<HTMLElement>(`[data-season-id="${CSS.escape(targetSeasonId)}"]`)
|
||||
const targetEpisode = findSeasonAnchorElement(list, targetSeasonId)
|
||||
if (!targetEpisode) {
|
||||
return
|
||||
}
|
||||
@@ -1170,6 +1239,25 @@ function TitleDetailsModal(props: {
|
||||
pendingSeasonScrollRef.current = null
|
||||
}, [details, seasonCacheVersion, seasonValue])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const list = episodeListRef.current
|
||||
const playTargetId = details?.playTargetId
|
||||
if (!list || !playTargetId || pendingSeasonScrollRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const focusKey = `${props.card.id}:${playTargetId}`
|
||||
if (initialEpisodeFocusKeyRef.current === focusKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!centerEpisodeInList(list, playTargetId, 'auto')) {
|
||||
return
|
||||
}
|
||||
|
||||
initialEpisodeFocusKeyRef.current = focusKey
|
||||
}, [details?.playTargetId, props.card.id, seasonCacheVersion])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const list = episodeListRef.current
|
||||
if (!list || pendingSeasonScrollRef.current) {
|
||||
@@ -1193,12 +1281,12 @@ function TitleDetailsModal(props: {
|
||||
|
||||
if (previousFirstSeasonId && currentFirstSeasonId && previousFirstSeasonId !== currentFirstSeasonId) {
|
||||
if (currentRenderedSeasonIds.includes(previousFirstSeasonId)) {
|
||||
const anchorCard = list.querySelector<HTMLElement>(`[data-season-id="${CSS.escape(previousFirstSeasonId)}"]`)
|
||||
const anchorCard = findSeasonAnchorElement(list, previousFirstSeasonId)
|
||||
if (anchorCard) {
|
||||
list.scrollLeft += anchorCard.offsetLeft
|
||||
}
|
||||
} else if (previousRenderedSeasonIds.includes(currentFirstSeasonId)) {
|
||||
const currentFirstCard = list.querySelector<HTMLElement>(`[data-season-id="${CSS.escape(currentFirstSeasonId)}"]`)
|
||||
const currentFirstCard = findSeasonAnchorElement(list, currentFirstSeasonId)
|
||||
if (currentFirstCard) {
|
||||
list.scrollLeft = Math.max(0, list.scrollLeft - currentFirstCard.offsetLeft)
|
||||
}
|
||||
@@ -1265,7 +1353,7 @@ function TitleDetailsModal(props: {
|
||||
return
|
||||
}
|
||||
|
||||
const preloadThreshold = list.clientWidth * 0.45
|
||||
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
|
||||
@@ -1326,14 +1414,9 @@ function TitleDetailsModal(props: {
|
||||
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?.seasons
|
||||
.filter((season, index) => Math.abs(index - resolvedSeasonIndex) <= 2)
|
||||
.flatMap((season) =>
|
||||
(seasonDetailsCacheRef.current.get(season.id)?.episodes || []).map((episode) => ({
|
||||
...episode,
|
||||
seasonId: season.id,
|
||||
}))
|
||||
) ?? []
|
||||
const visibleEpisodes = details
|
||||
? getVisibleSeasonEpisodes(details.seasons, resolvedSeasonIndex, seasonDetailsCacheRef.current)
|
||||
: []
|
||||
|
||||
function handleRequestClose() {
|
||||
if (!props.originRect || transitionState === 'closing') {
|
||||
@@ -1351,7 +1434,7 @@ function TitleDetailsModal(props: {
|
||||
const list = episodeListRef.current
|
||||
if (!list) return
|
||||
|
||||
const distance = Math.max(list.clientWidth * 0.72, 320)
|
||||
const distance = Math.max(list.clientWidth * EPISODE_SCROLL_DISTANCE_RATIO, 320)
|
||||
list.scrollBy({
|
||||
left: direction === 'left' ? -distance : distance,
|
||||
behavior: 'smooth',
|
||||
@@ -1382,13 +1465,46 @@ function TitleDetailsModal(props: {
|
||||
})
|
||||
}
|
||||
|
||||
function handleEpisodeWatchToggle(
|
||||
event: MouseEvent<HTMLButtonElement>,
|
||||
episodeId: string,
|
||||
watched: boolean | undefined,
|
||||
progress: number | undefined,
|
||||
playbackPositionTicks: number | undefined
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const nextWatched = !watched
|
||||
setEpisodeWatchBusyId(episodeId)
|
||||
updateEpisodeInCaches(episodeId, (episode) => ({
|
||||
...episode,
|
||||
watched: nextWatched,
|
||||
progress: nextWatched ? undefined : 0,
|
||||
playbackPositionTicks: nextWatched ? undefined : 0,
|
||||
}))
|
||||
|
||||
void api.setEpisodeWatchState({ episodeId, watched: nextWatched })
|
||||
.catch(() => {
|
||||
updateEpisodeInCaches(episodeId, (episode) => ({
|
||||
...episode,
|
||||
watched: watched ?? false,
|
||||
progress,
|
||||
playbackPositionTicks,
|
||||
}))
|
||||
})
|
||||
.finally(() => {
|
||||
setEpisodeWatchBusyId((current) => current === episodeId ? null : current)
|
||||
})
|
||||
}
|
||||
|
||||
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 className="modal-close omniarr-modal-close" onClick={props.onClose} type="button">Close</button>
|
||||
<button aria-label="Close" className="modal-close omniarr-modal-close" onClick={props.onClose} type="button">×</button>
|
||||
</div>
|
||||
<iframe
|
||||
allow="autoplay; fullscreen; encrypted-media"
|
||||
@@ -1424,7 +1540,7 @@ function TitleDetailsModal(props: {
|
||||
<div className="title-details-modal-content">
|
||||
<div className="title-details-hero" style={backgroundImage ? { backgroundImage: `url("${backgroundImage}")` } : undefined}>
|
||||
<div className="title-details-darken" />
|
||||
<button className="modal-close omniarr-modal-close" onClick={handleRequestClose} type="button">Close</button>
|
||||
<button aria-label="Close" className="modal-close omniarr-modal-close" onClick={handleRequestClose} type="button">×</button>
|
||||
</div>
|
||||
|
||||
<div className="title-details-body">
|
||||
@@ -1543,21 +1659,58 @@ function TitleDetailsModal(props: {
|
||||
ref={episodeListRef}
|
||||
>
|
||||
{visibleEpisodes.map((episode) => (
|
||||
<button
|
||||
className="episode-card"
|
||||
<div
|
||||
className={[
|
||||
'episode-card',
|
||||
episode.id === details.playTargetId ? 'is-next-up' : '',
|
||||
episode.progress && !episode.watched ? 'has-progress' : '',
|
||||
].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 })}
|
||||
type="button"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setPlayerTarget({ id: episode.id, title: episode.title })
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="episode-card-art">
|
||||
{episode.progress && !episode.watched ? (
|
||||
<div className="episode-card-progress" aria-hidden="true">
|
||||
<div className="episode-card-progress-bar" style={{ width: `${episode.progress}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
{episode.progress && !episode.watched ? <div className="episode-card-progress-glow" aria-hidden="true" /> : null}
|
||||
{episode.imageUrl ? <img alt={episode.title} loading="lazy" src={episode.imageUrl} /> : <div className="media-fallback" />}
|
||||
</div>
|
||||
<div className="episode-card-copy">
|
||||
<strong>{episode.title}</strong>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<span>{formatEpisodeMeta(episode.subtitle)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1602,6 +1755,14 @@ function DownloadGlyph() {
|
||||
)
|
||||
}
|
||||
|
||||
function CheckGlyph() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16">
|
||||
<path d="M6.45 11.2 3.4 8.15l1.05-1.05 2 2 5.1-5.1 1.05 1.05-6.15 6.15Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function formatEpisodeMeta(subtitle: string) {
|
||||
return subtitle.replace(/^Season\s+\d+\s*,\s*/i, '')
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
MediaCard,
|
||||
ServiceOptionsResponse,
|
||||
SessionResponse,
|
||||
EpisodeWatchStateRequest,
|
||||
TitleDetailsRequest,
|
||||
TitleDetailsResponse
|
||||
} from '../shared/contracts'
|
||||
@@ -83,6 +84,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ card, seasonId } satisfies TitleDetailsRequest),
|
||||
}),
|
||||
setEpisodeWatchState: (payload: EpisodeWatchStateRequest) =>
|
||||
request<{ ok: boolean }>('/api/episodes/watch-state', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
getDownloads: () => request<DownloadItem[]>('/api/downloads'),
|
||||
deleteDownload: (payload: { id: string }) =>
|
||||
request<{ ok: boolean }>('/api/downloads/delete', {
|
||||
|
||||
+156
-1
@@ -938,13 +938,22 @@ img {
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.65rem 0.9rem;
|
||||
padding: 0;
|
||||
background: rgba(6, 8, 14, 0.72);
|
||||
color: #eef4ff;
|
||||
font-size: 1.45rem;
|
||||
line-height: 1;
|
||||
text-indent: 0.06rem;
|
||||
padding-bottom: 0.06rem;
|
||||
}
|
||||
|
||||
|
||||
.omniarr-modal-close {
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
@@ -1537,6 +1546,7 @@ img {
|
||||
}
|
||||
|
||||
.episode-card-art {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 0.95rem;
|
||||
aspect-ratio: 16 / 9;
|
||||
@@ -1546,12 +1556,154 @@ img {
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.episode-card-art::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(90deg, #68abff, #ffd166);
|
||||
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;
|
||||
}
|
||||
|
||||
.episode-card.is-next-up .episode-card-art::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.episode-card.is-next-up.has-progress .episode-card-art::before {
|
||||
inset: 0;
|
||||
padding: 2px 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);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.episode-card-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.episode-card-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
height: 0.32rem;
|
||||
background: rgba(5, 8, 14, 0.58);
|
||||
}
|
||||
|
||||
.episode-card-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #68abff, #ffd166);
|
||||
box-shadow: 0 0 1rem rgba(104, 171, 255, 0.45);
|
||||
}
|
||||
|
||||
.episode-card-progress-glow {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
z-index: 1;
|
||||
height: 38%;
|
||||
background: linear-gradient(180deg, rgba(104, 171, 255, 0), rgba(104, 171, 255, 0.16));
|
||||
}
|
||||
|
||||
.episode-card-title-row {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.episode-card-title-row strong {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.episode-card-state-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.episode-card-watch-toggle svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.episode-card-watch-toggle {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: rgba(244, 247, 255, 0.9);
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease, transform 160ms ease, border-color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.episode-card-watch-toggle.is-watched {
|
||||
opacity: 1;
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.episode-card-watch-toggle.is-watched svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.episode-card:hover .episode-card-watch-toggle,
|
||||
.episode-card:focus-visible .episode-card-watch-toggle,
|
||||
.episode-card-watch-toggle:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.episode-card:hover .episode-card-watch-toggle:not(.is-watched),
|
||||
.episode-card:focus-visible .episode-card-watch-toggle:not(.is-watched),
|
||||
.episode-card-watch-toggle:not(.is-watched):focus-visible {
|
||||
border-style: dashed;
|
||||
border-color: rgba(244, 247, 255, 0.44);
|
||||
}
|
||||
|
||||
.episode-card-watch-toggle:hover,
|
||||
.episode-card-watch-toggle:focus-visible {
|
||||
transform: scale(1.04);
|
||||
background: rgba(7, 10, 16, 0.36);
|
||||
}
|
||||
|
||||
.episode-card-watch-toggle.is-watched:hover,
|
||||
.episode-card-watch-toggle.is-watched:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
|
||||
.episode-card-watch-toggle:not(.is-watched):hover,
|
||||
.episode-card-watch-toggle:not(.is-watched):focus-visible {
|
||||
border-style: dashed;
|
||||
border-color: rgba(244, 247, 255, 0.52);
|
||||
}
|
||||
|
||||
.episode-card-watch-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.episode-card:hover .episode-card-art,
|
||||
.episode-card:focus-visible .episode-card-art {
|
||||
box-shadow:
|
||||
@@ -1794,8 +1946,11 @@ img {
|
||||
color: #dbe2ee;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
text-indent: 0.06rem;
|
||||
padding-bottom: 0.06rem;
|
||||
}
|
||||
|
||||
|
||||
.empty-state {
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
|
||||
Reference in new issue
Block a user