feature: add actors

This commit is contained in:
WonderAliceUwU committed 2026-07-08 01:34:31 +02:00
1 parent 1550fcd065
commit ef886644a6
4 files changed
+431 -235

No files matched your search

+39 -4
View File
@@ -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)}`
);
}
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}