Merge pull request #7656 from dkanada/book-author-details

This commit is contained in:
Bill Thornton
2026-03-18 18:16:46 -04:00
committed by GitHub
5 changed files with 173 additions and 139 deletions
@@ -0,0 +1,110 @@
import React, { type FC } from 'react';
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import type { NameGuidPair } from '@jellyfin/sdk/lib/generated-client/models/name-guid-pair';
import { PersonKind } from '@jellyfin/sdk/lib/generated-client/models/person-kind';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import LinkButton from 'elements/emby-button/LinkButton';
import { appRouter } from 'components/router/appRouter';
import globalize from 'lib/globalize';
interface ItemDetailsMetadataListProps {
item: BaseItemDto;
type: BaseItemKind | PersonKind;
context: string;
}
const ItemDetailsMetadataList: FC<ItemDetailsMetadataListProps> = ({
type,
item,
context
}) => {
const items = getMetadataItems(type, item);
if (!items?.length) {
return null;
}
return (
<Box className='detailsGroupItem'>
<Typography className='label'>
{getLabel(type, items.length)}
</Typography>
<Box className='focuscontainer-x'>
{items.map((metadataItem, index) => (
<Box key={item.Id} component='span'>
{index > 0 ? ', ' : ''}
<LinkButton href={getLink(type, item, context, metadataItem)} className='button-link' style={{ color: 'inherit' }}>
{metadataItem.Name}
</LinkButton>
</Box>
))}
</Box>
</Box>
);
};
function getLabel(type: BaseItemKind | PersonKind, itemCount: number): string | null {
switch (type) {
case PersonKind.Author:
return globalize.translate(itemCount > 1 ? 'Authors' : 'Author');
case PersonKind.Director:
return globalize.translate(itemCount > 1 ? 'Directors' : 'Director');
case PersonKind.Writer:
return globalize.translate(itemCount > 1 ? 'Writers' : 'Writer');
case BaseItemKind.Studio:
return globalize.translate(itemCount > 1 ? 'Studios' : 'Studio');
case BaseItemKind.Genre:
return globalize.translate(itemCount > 1 ? 'Genres' : 'Genre');
}
return null;
}
function getLink(type: BaseItemKind | PersonKind, item: BaseItemDto, context: string, metadataItem: NameGuidPair): string {
const stubItem = {
Id: metadataItem.Id,
Name: metadataItem.Name,
Type: getRouteType(type, context),
ServerId: item.ServerId
};
return appRouter.getRouteUrl(stubItem, { context });
}
function getRouteType(type: BaseItemKind | PersonKind, context: string): string | null {
switch (type) {
case PersonKind.Author:
case PersonKind.Director:
case PersonKind.Writer:
return 'Person';
case BaseItemKind.Studio:
return 'Studio';
case BaseItemKind.Genre:
return context === 'music' ? 'MusicGenre' : 'Genre';
}
return null;
}
function getMetadataItems(type: BaseItemKind | PersonKind, item: BaseItemDto): NameGuidPair[] | null {
if (item.Type === BaseItemKind.BoxSet || item.Type === BaseItemKind.Playlist) {
return null;
}
switch (type) {
case PersonKind.Author:
case PersonKind.Director:
case PersonKind.Writer:
return item.People?.filter(person => person.Type === type).map(person => ({ Id: person.Id, Name: person.Name })) ?? null;
case BaseItemKind.Studio:
return item.Studios ?? null;
case BaseItemKind.Genre:
return item.GenreItems ?? null;
}
return null;
}
export default ItemDetailsMetadataList;
+1 -21
View File
@@ -126,27 +126,7 @@
<div class="seriesRecordingEditor"></div>
</div>
<div class="itemDetailsGroup">
<div class="detailsGroupItem genresGroup hide">
<div class="genresLabel label"></div>
<div class="genres content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem directorsGroup hide">
<div class="directorsLabel label"></div>
<div class="directors content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem writersGroup hide">
<div class="writersLabel label"></div>
<div class="writers content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem studiosGroup hide">
<div class="studiosLabel label"></div>
<div class="studios content focuscontainer-x"></div>
</div>
</div>
<div class="itemDetailsGroup"></div>
<div id="seriesTimerScheduleSection" class="verticalSection detailVerticalSection hide" style="margin-top: -3em;">
<h2 class="sectionTitle">${Schedule}</h2>
+39 -118
View File
@@ -19,6 +19,7 @@ import mediaInfo from 'components/mediainfo/mediainfo';
import layoutManager from 'components/layoutManager';
import listView from 'components/listview/listview';
import loading from 'components/loading/loading';
import ItemDetailsMetadataList from 'components/itemDetails/ItemDetailsMetadataList';
import { playbackManager } from 'components/playback/playbackmanager';
import { appRouter } from 'components/router/appRouter';
import itemShortcuts from 'components/shortcuts';
@@ -29,6 +30,7 @@ import { ServerConnections } from 'lib/jellyfin-apiclient';
import browser from 'scripts/browser';
import datetime from 'scripts/datetime';
import dom from 'utils/dom';
import { renderComponent } from 'utils/reactUtils';
import { download } from 'scripts/fileDownloader';
import libraryMenu from 'scripts/libraryMenu';
import * as userSettings from 'scripts/settings/userSettings';
@@ -551,6 +553,7 @@ function reloadFromItem(instance, page, params, item, user) {
const apiClient = ServerConnections.getApiClient(item.ServerId);
libraryMenu.setTitle('');
unmount(instance);
// Start rendering the artwork first
renderImage(page, item, apiClient);
@@ -566,7 +569,7 @@ function reloadFromItem(instance, page, params, item, user) {
// Render the main information for the item
renderName(item, page.querySelector('.nameContainer'), params.context);
renderDetails(page, item, apiClient, params.context);
renderDetails(page, instance, item, apiClient, params.context);
renderTrackSelections(page, instance, item);
renderSeriesTimerEditor(page, item, apiClient, user);
@@ -930,118 +933,6 @@ function renderOverview(page, item) {
}
}
function renderGenres(page, item, context = inferContext(item)) {
const genres = item.GenreItems || [];
const type = context === 'music' ? 'MusicGenre' : 'Genre';
const html = genres.map(function (p) {
return '<a style="color:inherit;" class="button-link" is="emby-linkbutton" href="' + appRouter.getRouteUrl({
Name: p.Name,
Type: type,
ServerId: item.ServerId,
Id: p.Id
}, {
context: context
}) + '">' + escapeHtml(p.Name) + '</a>';
}).join(', ');
const genresLabel = page.querySelector('.genresLabel');
genresLabel.innerHTML = globalize.translate(genres.length > 1 ? 'Genres' : 'Genre');
const genresValue = page.querySelector('.genres');
genresValue.innerHTML = html;
const genresGroup = page.querySelector('.genresGroup');
if (genres.length) {
genresGroup.classList.remove('hide');
} else {
genresGroup.classList.add('hide');
}
}
function renderWriter(page, item, context) {
const writers = (item.People || []).filter(function (person) {
return person.Type === 'Writer';
});
const html = writers.map(function (person) {
return '<a style="color:inherit;" class="button-link" is="emby-linkbutton" href="' + appRouter.getRouteUrl({
Name: person.Name,
Type: 'Person',
ServerId: item.ServerId,
Id: person.Id
}, {
context: context
}) + '">' + escapeHtml(person.Name) + '</a>';
}).join(', ');
const writersLabel = page.querySelector('.writersLabel');
writersLabel.innerHTML = globalize.translate(writers.length > 1 ? 'Writers' : 'Writer');
const writersValue = page.querySelector('.writers');
writersValue.innerHTML = html;
const writersGroup = page.querySelector('.writersGroup');
if (writers.length) {
writersGroup.classList.remove('hide');
} else {
writersGroup.classList.add('hide');
}
}
function renderDirector(page, item, context) {
const directors = (item.People || []).filter(function (person) {
return person.Type === 'Director';
});
const html = directors.map(function (person) {
return '<a style="color:inherit;" class="button-link" is="emby-linkbutton" href="' + appRouter.getRouteUrl({
Name: person.Name,
Type: 'Person',
ServerId: item.ServerId,
Id: person.Id
}, {
context: context
}) + '">' + escapeHtml(person.Name) + '</a>';
}).join(', ');
const directorsLabel = page.querySelector('.directorsLabel');
directorsLabel.innerHTML = globalize.translate(directors.length > 1 ? 'Directors' : 'Director');
const directorsValue = page.querySelector('.directors');
directorsValue.innerHTML = html;
const directorsGroup = page.querySelector('.directorsGroup');
if (directors.length) {
directorsGroup.classList.remove('hide');
} else {
directorsGroup.classList.add('hide');
}
}
function renderStudio(page, item, context) {
// The list of studios can be massive for collections of items
if ([BaseItemKind.BoxSet, BaseItemKind.Playlist].includes(item.Type)) return;
const studios = item.Studios || [];
const html = studios.map(function (studio) {
return '<a style="color:inherit;" class="button-link" is="emby-linkbutton" href="' + appRouter.getRouteUrl({
Name: studio.Name,
Type: 'Studio',
ServerId: item.ServerId,
Id: studio.Id
}, {
context: context
}) + '">' + escapeHtml(studio.Name) + '</a>';
}).join(', ');
const studiosLabel = page.querySelector('.studiosLabel');
studiosLabel.innerText = globalize.translate(studios.length > 1 ? 'Studios' : 'Studio');
const studiosValue = page.querySelector('.studios');
studiosValue.innerHTML = html;
const studiosGroup = page.querySelector('.studiosGroup');
studiosGroup.classList.toggle('hide', !studios.length);
}
function renderMiscInfo(page, item) {
const primaryItemMiscInfo = page.querySelectorAll('.itemMiscInfo-primary');
@@ -1085,14 +976,32 @@ function renderTagline(page, item) {
}
}
function renderDetails(page, item, apiClient, context) {
function renderDetails(page, instance, item, apiClient, context) {
const itemDetailsGroup = page.querySelector('.itemDetailsGroup');
if (itemDetailsGroup) {
itemDetailsGroup.replaceChildren();
const metadataTypes = [
PersonKind.Author,
PersonKind.Director,
PersonKind.Writer,
BaseItemKind.Studio,
BaseItemKind.Genre
];
for (const type of metadataTypes) {
const renderTarget = document.createElement('div');
const unmountMethod = renderComponent(ItemDetailsMetadataList, { type, item, context: inferContext(item) }, renderTarget);
instance._unmount.push(unmountMethod);
itemDetailsGroup.appendChild(renderTarget);
}
}
renderSimilarItems(page, item, context);
renderMoreFromSeason(page, item, apiClient);
renderMoreFromArtist(page, item, apiClient);
renderDirector(page, item, context);
renderStudio(page, item, context);
renderWriter(page, item, context);
renderGenres(page, item, context);
renderChannelGuide(page, apiClient, item);
renderTagline(page, item);
renderOverview(page, item);
@@ -1906,6 +1815,14 @@ function ItemDetailPage() {
self.renderGuestCast = renderGuestCast;
}
function unmount(instance) {
for (const unmountMethod of instance._unmount) {
unmountMethod();
}
instance._unmount = [];
}
function bindAll(view, selector, eventName, fn) {
const elems = view.querySelectorAll(selector);
@@ -2099,6 +2016,8 @@ export default function (view, params) {
function init() {
const apiClient = getApiClient();
self._unmount = [];
bindAll(view, '.btnPlay', 'click', onPlayClick);
bindAll(view, '.btnReplay', 'click', onPlayClick);
bindAll(view, '.btnInstantMix', 'click', onInstantMixClick);
@@ -2145,6 +2064,8 @@ export default function (view, params) {
libraryMenu.setTransparentMenu(false);
});
view.addEventListener('viewdestroy', function () {
unmount(self);
currentItem = null;
self._currentPlaybackMediaSources = null;
self.currentRecordingFields = null;
+22
View File
@@ -68,6 +68,12 @@ function renderItems(page, item) {
});
}
// TODO add a check when the API reports BookCount or PersonRoles
sections.push({
name: globalize.translate('Books'),
type: 'Book'
});
const elem = page.querySelector('#childrenContent');
elem.innerHTML = sections.map(function (section) {
let html = '';
@@ -211,6 +217,22 @@ function renderSection(item, element, type) {
});
break;
case 'Book':
loadItems(element, item, type, {
IncludeItemTypes: 'Book',
SortBy: 'ProductionYear,SortName',
SortOrder: 'Descending,Ascending',
Limit: 10
}, {
shape: 'overflowPortrait',
showTitle: true,
centerText: true,
overlayMoreButton: true,
overlayText: false,
showYear: true
});
break;
case 'MusicArtist':
loadItems(element, item, type, {
MediaTypes: '',
+1
View File
@@ -69,6 +69,7 @@
"AsManyAsPossible": "As many as possible",
"AspectRatio": "Aspect Ratio",
"Audio": "Audio",
"Authors": "Authors",
"Author": "Author",
"Authorize": "Authorize",
"AuthProviderHelp": "Select an authentication provider to be used to authenticate this user's password.",