diff --git a/src/components/itemDetails/ItemDetailsMetadataList.tsx b/src/components/itemDetails/ItemDetailsMetadataList.tsx new file mode 100644 index 0000000000..e8fe6f626f --- /dev/null +++ b/src/components/itemDetails/ItemDetailsMetadataList.tsx @@ -0,0 +1,109 @@ +import React, { type FC } from 'react'; +import { NameGuidPair, PersonKind } from '@jellyfin/sdk/lib/generated-client'; +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'; +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 = ({ + type, + item, + context +}) => { + const items = getMetadataItems(type, item); + + if (!items?.length) { + return null; + } + + return ( + + + {getLabel(type, items.length)} + + + {items.map((metadataItem, index) => ( + + {index > 0 ? ', ' : ''} + + {metadataItem.Name} + + + ))} + + + ); +}; + +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; diff --git a/src/controllers/itemDetails/index.html b/src/controllers/itemDetails/index.html index d9a7c353f9..e51ac50e64 100644 --- a/src/controllers/itemDetails/index.html +++ b/src/controllers/itemDetails/index.html @@ -126,27 +126,7 @@

-
-
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
-
+

${Schedule}

diff --git a/src/controllers/itemDetails/index.js b/src/controllers/itemDetails/index.js index 0c7c52ec80..060d3fb1fd 100644 --- a/src/controllers/itemDetails/index.js +++ b/src/controllers/itemDetails/index.js @@ -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 '' + escapeHtml(p.Name) + ''; - }).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 '' + escapeHtml(person.Name) + ''; - }).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 '' + escapeHtml(person.Name) + ''; - }).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 '' + escapeHtml(studio.Name) + ''; - }).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 unmount = renderComponent(ItemDetailsMetadataList, { type, item, context: inferContext(item) }, renderTarget); + + instance._unmount.push(unmount); + 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 unmount of instance._unmount) { + unmount(); + } + + 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; diff --git a/src/scripts/itemsByName.js b/src/scripts/itemsByName.js index f7ab817e3e..3762faf7ba 100644 --- a/src/scripts/itemsByName.js +++ b/src/scripts/itemsByName.js @@ -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: '', diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 11911ec399..b3056d6571 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -67,6 +67,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.",