feature: add episode progress indicator

This commit is contained in:
WonderAliceUwU committed 2026-07-07 11:36:38 +02:00
1 parent 0cfde11d53
commit 98db35d367
6 files changed
+473 -80

No files matched your search

+10 -1
View File
@@ -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
View File
@@ -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,
+8
View File
@@ -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
View File
@@ -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, '')
}
+6
View File
@@ -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
View File
@@ -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;