mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
feature: add actors
This commit is contained in:
1 parent
1550fcd065
commit
ef886644a6
4 files changed
+431
-235
No files matched your search
+39
-4
@@ -14,7 +14,8 @@ import type {
|
||||
TitleDetailsEpisode,
|
||||
TitleDetailsRequest,
|
||||
TitleDetailsResponse,
|
||||
TitleDetailsSeason
|
||||
TitleDetailsSeason,
|
||||
TitleDetailsCastMember
|
||||
} from '../../shared/contracts.js';
|
||||
|
||||
const TMDB_IMAGE = 'https://image.tmdb.org/t/p';
|
||||
@@ -458,6 +459,7 @@ export async function getTitleDetails(
|
||||
const effectiveBackdropUrl = backdropUrl || resolveBackdrop(seriesItem || item, card);
|
||||
|
||||
if (!isSeries) {
|
||||
const movieDetails = tmdbId ? await getTmdbMovieSummary(config, tmdbId) : null;
|
||||
return {
|
||||
id: card.id,
|
||||
tmdbId,
|
||||
@@ -472,7 +474,8 @@ export async function getTitleDetails(
|
||||
backdropUrl: effectiveBackdropUrl,
|
||||
logoUrl: logoUrl || card.logoUrl,
|
||||
seasons: [],
|
||||
episodes: []
|
||||
episodes: [],
|
||||
cast: buildTitleCast(movieDetails, 'movie')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -527,10 +530,42 @@ export async function getTitleDetails(
|
||||
logoUrl: logoUrl || card.logoUrl,
|
||||
selectedSeasonId: selectedSeason?.id,
|
||||
seasons,
|
||||
episodes
|
||||
episodes,
|
||||
cast: buildTitleCast(tmdbSeries, 'series')
|
||||
};
|
||||
}
|
||||
|
||||
function buildTitleCast(
|
||||
tmdbPayload: Record<string, unknown> | null,
|
||||
mediaType: 'movie' | 'series'
|
||||
): TitleDetailsCastMember[] {
|
||||
const creditsKey = mediaType === 'series' ? 'aggregate_credits' : 'credits';
|
||||
const castEntries = asArray(asRecord(tmdbPayload?.[creditsKey])?.cast)
|
||||
.map((entry) => asRecord(entry))
|
||||
.slice(0, 20);
|
||||
|
||||
return castEntries.map((entry) => ({
|
||||
id: String(entry.id || ''),
|
||||
name: String(entry.name || ''),
|
||||
role: String(
|
||||
entry.character
|
||||
|| asArray(entry.roles).map((role) => String(asRecord(role).character || '')).find(Boolean)
|
||||
|| entry.known_for_department
|
||||
|| ''
|
||||
),
|
||||
imageUrl: typeof entry.profile_path === 'string' && entry.profile_path
|
||||
? `${TMDB_IMAGE}/w342${entry.profile_path}`
|
||||
: ''
|
||||
})).filter((member) => Boolean(member.id && member.name));
|
||||
}
|
||||
|
||||
async function getTmdbMovieSummary(config: AppConfig, tmdbId: number): Promise<Record<string, unknown> | null> {
|
||||
return tmdbFetch(
|
||||
config,
|
||||
`/movie/${tmdbId}?append_to_response=credits,images&language=${encodeURIComponent(config.preferences.language)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function enrichDownloadItem(
|
||||
config: AppConfig,
|
||||
item: DownloadItem,
|
||||
@@ -789,7 +824,7 @@ async function getPreferredTitleLogo(
|
||||
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)}`
|
||||
`/tv/${tmdbId}?append_to_response=external_ids,aggregate_credits&language=${encodeURIComponent(config.preferences.language)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,13 @@ export interface TitleDetailsSeason {
|
||||
downloadLabel?: string;
|
||||
}
|
||||
|
||||
export interface TitleDetailsCastMember {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export interface TitleDetailsResponse {
|
||||
id: string;
|
||||
tmdbId?: number;
|
||||
@@ -159,6 +166,7 @@ export interface TitleDetailsResponse {
|
||||
selectedSeasonId?: string;
|
||||
seasons: TitleDetailsSeason[];
|
||||
episodes: TitleDetailsEpisode[];
|
||||
cast: TitleDetailsCastMember[];
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
|
||||
+273
-229
@@ -20,6 +20,7 @@ import {
|
||||
type MediaCard,
|
||||
type SelectOption,
|
||||
type ServiceOptionsResponse,
|
||||
type TitleDetailsCastMember,
|
||||
type TitleDetailsEpisode,
|
||||
type TitleDetailsResponse,
|
||||
} from '../shared/contracts'
|
||||
@@ -1319,6 +1320,7 @@ function TitleDetailsModal(props: {
|
||||
|| (canRequest && !details?.sonarrSeriesId && typeof activeSeasonNumber === 'number')
|
||||
)
|
||||
const canDeleteSeasonAction = Boolean(activeSeasonMeta?.canDelete)
|
||||
const shouldShowDownloadSeasonAction = canDownloadSeasonAction && !canDeleteSeasonAction
|
||||
|
||||
useEffect(() => {
|
||||
if (!details || details.mediaType !== 'series') {
|
||||
@@ -1518,258 +1520,286 @@ function TitleDetailsModal(props: {
|
||||
|
||||
<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 ? (
|
||||
<img alt={displayTitle} className="title-details-logo" src={logoUrl} />
|
||||
) : (
|
||||
<h2 className="title-details-title">{displayTitle}</h2>
|
||||
)}
|
||||
</div>
|
||||
<div className="title-details-summary-content">
|
||||
<p className="title-details-overview">
|
||||
{isOverviewExpanded ? displayOverview : truncatedOverview}
|
||||
{shouldShowOverviewToggle ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
className="title-details-overview-toggle"
|
||||
onClick={() => setIsOverviewExpanded((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{isOverviewExpanded ? 'less' : 'more'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="title-details-summary-header">
|
||||
{canPlay ? (
|
||||
<button className="secondary-button title-action-button title-action-button-play" onClick={() => setPlayerTarget({ id: playId!, title: playLabel })} type="button">
|
||||
<PlayGlyph />
|
||||
<span>Play</span>
|
||||
</button>
|
||||
) : canRequest ? (
|
||||
<button className="secondary-button title-action-button" onClick={() => void props.onRequest(props.card)} type="button">
|
||||
<DownloadGlyph />
|
||||
<span>Request</span>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="title-details-primary">
|
||||
{detailsError ? <p className="error-banner">{detailsError}</p> : null}
|
||||
{playerTarget && jellyfinPlayerUrl ? null : (
|
||||
<div className="title-details-summary">
|
||||
<div className="title-details-hero-heading">
|
||||
{logoUrl ? (
|
||||
<img alt={displayTitle} className="title-details-logo" src={logoUrl} />
|
||||
) : (
|
||||
<h2 className="title-details-title">{displayTitle}</h2>
|
||||
)}
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
{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>
|
||||
<select
|
||||
className="title-season-select"
|
||||
onChange={(event) => handleSeasonSelectChange(event.target.value)}
|
||||
value={seasonValue}
|
||||
<div className="title-details-summary-content">
|
||||
<p className="title-details-overview">
|
||||
{isOverviewExpanded ? displayOverview : truncatedOverview}
|
||||
{shouldShowOverviewToggle ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
className="title-details-overview-toggle"
|
||||
onClick={() => setIsOverviewExpanded((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{details.seasons.map((season) => (
|
||||
<option key={season.id} value={season.id}>
|
||||
{season.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{isOverviewExpanded ? 'less' : 'more'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<div className="title-season-divider" aria-hidden="true" />
|
||||
<div className="title-season-badge">
|
||||
<strong>{activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length}</strong>
|
||||
<span>{(activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length) === 1 ? 'episode' : 'episodes'}</span>
|
||||
</div>
|
||||
{canDownloadSeasonAction || canDeleteSeasonAction ? (
|
||||
</p>
|
||||
<div className="title-details-summary-header">
|
||||
{canPlay ? (
|
||||
<button className="secondary-button title-action-button title-action-button-play" onClick={() => setPlayerTarget({ id: playId!, title: playLabel })} type="button">
|
||||
<PlayGlyph />
|
||||
<span>Play</span>
|
||||
</button>
|
||||
) : canRequest ? (
|
||||
<button className="secondary-button title-action-button" onClick={() => void props.onRequest(props.card)} type="button">
|
||||
<DownloadGlyph />
|
||||
<span>Request</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
{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>
|
||||
<select
|
||||
className="title-season-select"
|
||||
onChange={(event) => handleSeasonSelectChange(event.target.value)}
|
||||
value={seasonValue}
|
||||
>
|
||||
{details.seasons.map((season) => (
|
||||
<option key={season.id} value={season.id}>
|
||||
{season.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="title-season-divider" aria-hidden="true" />
|
||||
<div className="title-season-badge">
|
||||
<strong>{activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length}</strong>
|
||||
<span>{(activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length) === 1 ? 'episode' : 'episodes'}</span>
|
||||
</div>
|
||||
{shouldShowDownloadSeasonAction || 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 ? (
|
||||
{shouldShowDownloadSeasonAction || activeSeasonMeta?.isDownloading ? <div className="title-season-divider title-season-action-divider" aria-hidden="true" /> : null}
|
||||
{shouldShowDownloadSeasonAction || activeSeasonMeta?.isDownloading ? (
|
||||
activeSeasonDownloadStatus === 'downloading' ? (
|
||||
<div
|
||||
aria-label="Season downloading"
|
||||
className="title-season-progress"
|
||||
role="img"
|
||||
title={activeSeasonMeta?.downloadLabel || '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
|
||||
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>
|
||||
) : (
|
||||
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="Download season"
|
||||
className="title-season-action"
|
||||
disabled={seasonActionBusy === 'download'}
|
||||
onClick={() => void handleSeasonDownload()}
|
||||
aria-label="Delete season files"
|
||||
className="title-season-action title-season-action-delete"
|
||||
disabled={seasonActionBusy === 'delete'}
|
||||
onClick={() => void handleSeasonDelete()}
|
||||
type="button"
|
||||
>
|
||||
<DownloadGlyph />
|
||||
<TrashGlyph />
|
||||
</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 ? (
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{hasNextSeason ? (
|
||||
<button
|
||||
aria-label="Next season"
|
||||
className="title-season-nav next"
|
||||
onClick={() => changeSeason(1)}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'episode-dock',
|
||||
episodeScrollState.canScrollLeft ? 'show-left-fade' : '',
|
||||
episodeScrollState.canScrollRight ? 'show-right-fade' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{episodeScrollState.canScrollLeft ? (
|
||||
<button
|
||||
aria-label="Next season"
|
||||
className="title-season-nav next"
|
||||
onClick={() => changeSeason(1)}
|
||||
aria-label="Scroll episodes left"
|
||||
className="episode-dock-arrow left"
|
||||
onClick={() => scrollEpisodes('left')}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
{episodeScrollState.canScrollRight ? (
|
||||
<button
|
||||
aria-label="Scroll episodes right"
|
||||
className="episode-dock-arrow right"
|
||||
onClick={() => scrollEpisodes('right')}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="episode-list"
|
||||
ref={episodeListRef}
|
||||
>
|
||||
{visibleEpisodes.map((episode) => (
|
||||
<div
|
||||
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={() => {
|
||||
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={episode.availableInJellyfin ? 'button' : undefined}
|
||||
tabIndex={episode.availableInJellyfin ? 0 : -1}
|
||||
>
|
||||
<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">
|
||||
<div className="episode-card-title-row">
|
||||
<strong>{episode.title}</strong>
|
||||
<div className="episode-card-state-icons">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'episode-dock',
|
||||
episodeScrollState.canScrollLeft ? 'show-left-fade' : '',
|
||||
episodeScrollState.canScrollRight ? 'show-right-fade' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{episodeScrollState.canScrollLeft ? (
|
||||
<button
|
||||
aria-label="Scroll episodes left"
|
||||
className="episode-dock-arrow left"
|
||||
onClick={() => scrollEpisodes('left')}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
{episodeScrollState.canScrollRight ? (
|
||||
<button
|
||||
aria-label="Scroll episodes right"
|
||||
className="episode-dock-arrow right"
|
||||
onClick={() => scrollEpisodes('right')}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="episode-list"
|
||||
ref={episodeListRef}
|
||||
>
|
||||
{visibleEpisodes.map((episode) => (
|
||||
<div
|
||||
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={() => {
|
||||
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={episode.availableInJellyfin ? 'button' : undefined}
|
||||
tabIndex={episode.availableInJellyfin ? 0 : -1}
|
||||
>
|
||||
<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">
|
||||
<div className="episode-card-title-row">
|
||||
<strong>{episode.title}</strong>
|
||||
<div className="episode-card-state-icons">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{details?.cast.length && details.mediaType === 'movie' ? (
|
||||
<div className="title-cast-panel title-cast-panel-inline">
|
||||
<div className="title-cast-header">
|
||||
<h3>Cast & Crew</h3>
|
||||
</div>
|
||||
<div className="title-cast-list">
|
||||
{details.cast.map((member) => (
|
||||
<CastCard key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{details?.cast.length && details.mediaType === 'series' && !(playerTarget && jellyfinPlayerUrl) ? (
|
||||
<div className="title-cast-shell">
|
||||
<div className="title-cast-panel">
|
||||
<div className="title-cast-header">
|
||||
<h3>Cast & Crew</h3>
|
||||
</div>
|
||||
<div className="title-cast-list">
|
||||
{details.cast.map((member) => (
|
||||
<CastCard key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1790,6 +1820,20 @@ function captureOriginRect(element: HTMLElement): ModalOriginRect {
|
||||
}
|
||||
}
|
||||
|
||||
function CastCard(props: { member: TitleDetailsCastMember }) {
|
||||
return (
|
||||
<article className="cast-card">
|
||||
<div className="cast-card-art">
|
||||
{props.member.imageUrl ? <img alt={props.member.name} loading="lazy" src={props.member.imageUrl} /> : <div className="cast-card-fallback">{props.member.name.slice(0, 1).toUpperCase()}</div>}
|
||||
</div>
|
||||
<div className="cast-card-copy">
|
||||
<strong>{props.member.name}</strong>
|
||||
{props.member.role ? <span>{props.member.role}</span> : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayGlyph() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="title-action-icon" viewBox="0 0 16 16">
|
||||
|
||||
+111
-2
@@ -1224,13 +1224,13 @@ img {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -1415,6 +1415,10 @@ img {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.title-details-primary {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.title-details-summary {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -1471,6 +1475,111 @@ img {
|
||||
gap: 1.3rem;
|
||||
}
|
||||
|
||||
.title-cast-shell {
|
||||
padding: 0 1.5rem 1.6rem;
|
||||
background: #090b10;
|
||||
}
|
||||
|
||||
.title-cast-shell .title-cast-panel {
|
||||
width: min(100%, 84rem);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title-cast-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.title-cast-header h3 {
|
||||
margin: 0;
|
||||
color: #eef4ff;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.title-cast-list {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0.15rem 0 0.45rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.title-cast-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cast-card {
|
||||
flex: 0 0 8.8rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.cast-card-art {
|
||||
width: 7.8rem;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)),
|
||||
rgba(255, 255, 255, 0.04);
|
||||
box-shadow:
|
||||
0 1rem 2rem rgba(0, 0, 0, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.cast-card-art img,
|
||||
.cast-card-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cast-card-art img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cast-card-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #68abff, #ffd166);
|
||||
color: #08101b;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cast-card-copy {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cast-card-copy strong,
|
||||
.cast-card-copy span {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cast-card-copy strong {
|
||||
color: #eef4ff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cast-card-copy span {
|
||||
color: #96a4bd;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title-details-body.has-embedded-player .title-seasons-panel {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user