From d366f6acf786e4d36c690d76741c5a0058c852b4 Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Fri, 16 Oct 2026 03:38:09 -0700 Subject: [PATCH 1/3] Expand "Hide Media Description on Details Page" into granular settings - Add 11 synced settings in defaultSettings and SettingsContext: - Local: Movie, Show, Season, Episode, BoxSet, Book, Person, Music - Seerr: SeerrMovie, SeerrShow, SeerrCollection - Add MediaDescriptionVisibilityView settings subpage with Local and Seerr sections - Wire mediaDescriptionVisibility navigation row in settingsSchema and Settings.js - Update hidesMediaDescription to evaluate granular settings with legacy fallback - Respect description hiding across all detail screens (Person, Album, Artist, AudioTrack, Playlist) - Update kidsModeSettings to force all description hiding toggles on - Add unit tests for local, Seerr, Playlist, and Kids Mode description visibility --- packages/app/src/context/SettingsContext.js | 6 +- packages/app/src/context/defaultSettings.js | 11 ++ packages/app/src/utils/kidsMode.js | 11 ++ packages/app/src/utils/kidsMode.test.js | 15 ++- packages/app/src/views/Details/AlbumScreen.js | 3 +- .../app/src/views/Details/ArtistScreen.js | 3 +- .../app/src/views/Details/AudioTrackScreen.js | 3 +- packages/app/src/views/Details/Details.js | 1 + .../app/src/views/Details/PersonScreen.js | 3 +- .../app/src/views/Details/PlaylistScreen.js | 5 +- .../app/src/views/Details/detailsMedia.js | 63 +++++++++- .../src/views/Details/detailsMedia.test.js | 49 ++++++++ .../Details/nouveau/NouveauDetailContent.js | 6 +- packages/app/src/views/Person/Person.js | 10 +- .../app/src/views/SeerrPerson/SeerrPerson.js | 12 +- .../MediaDescriptionVisibilityView.js | 114 ++++++++++++++++++ packages/app/src/views/Settings/Settings.js | 16 ++- .../app/src/views/Settings/settingsSchema.js | 10 +- 18 files changed, 319 insertions(+), 22 deletions(-) create mode 100644 packages/app/src/views/Settings/MediaDescriptionVisibilityView.js diff --git a/packages/app/src/context/SettingsContext.js b/packages/app/src/context/SettingsContext.js index 5381d85bf..f8c5b89e2 100644 --- a/packages/app/src/context/SettingsContext.js +++ b/packages/app/src/context/SettingsContext.js @@ -287,7 +287,11 @@ export const SYNCABLE_KEYS = [ 'mediaBarOverlayColor', 'mediaBarOverlayOpacity', 'homeRows', 'homeRowsStyle', 'modernCardsOnMyMediaRow', 'detailScreenStyle', 'detailExpandedTabs', 'fullScreenRows', 'homeRowsPosterSize', 'useSeriesThumbnails', 'episodePreviewEnabled', 'previewAudioEnabled', 'previewVolume', - 'hideDetailsMediaDescription', 'detailUseSeriesThumbnails', 'hideHomeMediaDescription', + 'hideDetailsMediaDescription', + 'hideDetailsMediaDescriptionMovie', 'hideDetailsMediaDescriptionShow', 'hideDetailsMediaDescriptionSeason', 'hideDetailsMediaDescriptionEpisode', + 'hideDetailsMediaDescriptionBoxSet', 'hideDetailsMediaDescriptionBook', 'hideDetailsMediaDescriptionPerson', 'hideDetailsMediaDescriptionMusic', + 'hideDetailsMediaDescriptionSeerrMovie', 'hideDetailsMediaDescriptionSeerrShow', 'hideDetailsMediaDescriptionSeerrCollection', + 'detailUseSeriesThumbnails', 'hideHomeMediaDescription', 'personalRatingStyle', 'recentlyReleasedSeriesType', 'mergeRecentRowsByType', 'playlistsGroupByType', 'groupItemsIntoCollections', 'useDetailedSubHeadings', 'showMediaDetailsOnLibraryPage', 'hideBackdropsInLibraries', 'libraryStyle', 'musicLibraryStyle', 'syncplayEnabled', 'syncplayAutoOpen', diff --git a/packages/app/src/context/defaultSettings.js b/packages/app/src/context/defaultSettings.js index f63162fd9..9be80ace7 100644 --- a/packages/app/src/context/defaultSettings.js +++ b/packages/app/src/context/defaultSettings.js @@ -258,6 +258,17 @@ export const defaultSettings = { personalRatingStyle: 'thumbs', detailExpandedTabs: true, hideDetailsMediaDescription: false, + hideDetailsMediaDescriptionMovie: false, + hideDetailsMediaDescriptionShow: false, + hideDetailsMediaDescriptionSeason: false, + hideDetailsMediaDescriptionEpisode: false, + hideDetailsMediaDescriptionBoxSet: false, + hideDetailsMediaDescriptionBook: false, + hideDetailsMediaDescriptionPerson: false, + hideDetailsMediaDescriptionMusic: false, + hideDetailsMediaDescriptionSeerrMovie: false, + hideDetailsMediaDescriptionSeerrShow: false, + hideDetailsMediaDescriptionSeerrCollection: false, detailUseSeriesThumbnails: false, hideHomeMediaDescription: false, homeRowOverlay: true, diff --git a/packages/app/src/utils/kidsMode.js b/packages/app/src/utils/kidsMode.js index 37572a944..f7ba80aee 100644 --- a/packages/app/src/utils/kidsMode.js +++ b/packages/app/src/utils/kidsMode.js @@ -24,6 +24,17 @@ const KIDS_MODE_DETAILS = { detailShowTechnicalDetails: false, // Named for hiding, so the mode turns it on to leave the description out. hideDetailsMediaDescription: true, + hideDetailsMediaDescriptionMovie: true, + hideDetailsMediaDescriptionShow: true, + hideDetailsMediaDescriptionSeason: true, + hideDetailsMediaDescriptionEpisode: true, + hideDetailsMediaDescriptionBoxSet: true, + hideDetailsMediaDescriptionBook: true, + hideDetailsMediaDescriptionPerson: true, + hideDetailsMediaDescriptionMusic: true, + hideDetailsMediaDescriptionSeerrMovie: true, + hideDetailsMediaDescriptionSeerrShow: true, + hideDetailsMediaDescriptionSeerrCollection: true, detailUseSeriesThumbnails: false, // The online source is an outside catalogue no parental rating reaches, so recommendations stay // inside the server's own library. diff --git a/packages/app/src/utils/kidsMode.test.js b/packages/app/src/utils/kidsMode.test.js index f21c65ae5..ce229dfc0 100644 --- a/packages/app/src/utils/kidsMode.test.js +++ b/packages/app/src/utils/kidsMode.test.js @@ -211,8 +211,19 @@ describe('the details screen', () => { // The preference is named for hiding, so leaving the description out means forcing it on. test('hiding the description means forcing its flag on, not off', () => { - expect(kidsModeSettings({...stored, kidsModeEnabled: true}).hideDetailsMediaDescription) - .toBe(true); + const effective = kidsModeSettings({...stored, kidsModeEnabled: true}); + expect(effective.hideDetailsMediaDescription).toBe(true); + expect(effective.hideDetailsMediaDescriptionMovie).toBe(true); + expect(effective.hideDetailsMediaDescriptionShow).toBe(true); + expect(effective.hideDetailsMediaDescriptionSeason).toBe(true); + expect(effective.hideDetailsMediaDescriptionEpisode).toBe(true); + expect(effective.hideDetailsMediaDescriptionBoxSet).toBe(true); + expect(effective.hideDetailsMediaDescriptionBook).toBe(true); + expect(effective.hideDetailsMediaDescriptionPerson).toBe(true); + expect(effective.hideDetailsMediaDescriptionMusic).toBe(true); + expect(effective.hideDetailsMediaDescriptionSeerrMovie).toBe(true); + expect(effective.hideDetailsMediaDescriptionSeerrShow).toBe(true); + expect(effective.hideDetailsMediaDescriptionSeerrCollection).toBe(true); }); // Read never written, so the stored style still syncs as the user picked it. diff --git a/packages/app/src/views/Details/AlbumScreen.js b/packages/app/src/views/Details/AlbumScreen.js index 7bec9ea55..1f3e27246 100644 --- a/packages/app/src/views/Details/AlbumScreen.js +++ b/packages/app/src/views/Details/AlbumScreen.js @@ -8,6 +8,7 @@ import {DETAIL_ICON_PATHS} from './detailIcons'; import {SpottableDiv, HorizontalContainer} from './detailsSpottables'; import {handleSeasonButtonKeyDown} from './detailsFocus'; import {PosterBadges, WatchedCheckIcon} from './DetailBadges'; +import {hidesMediaDescription} from './detailsMedia'; import css from './Details.module.less'; @@ -112,7 +113,7 @@ const AlbumScreen = ({ })} - {item.Overview && ( + {item.Overview && !hidesMediaDescription(item, settings) && (

{item.Overview}

diff --git a/packages/app/src/views/Details/ArtistScreen.js b/packages/app/src/views/Details/ArtistScreen.js index a2d34aac8..39d8eb8b6 100644 --- a/packages/app/src/views/Details/ArtistScreen.js +++ b/packages/app/src/views/Details/ArtistScreen.js @@ -6,6 +6,7 @@ import {getImageUrl} from '../../utils/helpers'; import {isMdblistEnabled} from '../../services/mdblistApi'; import {DETAIL_ICON_PATHS} from './detailIcons'; import {SpottableDiv, HorizontalContainer} from './detailsSpottables'; +import {hidesMediaDescription} from './detailsMedia'; import css from './Details.module.less'; @@ -39,7 +40,7 @@ const ArtistScreen = ({

{item.Name}

- {item.Overview &&

{item.Overview}

} + {item.Overview && !hidesMediaDescription(item, settings) &&

{item.Overview}

} {artistAlbums.length > 0 && ( diff --git a/packages/app/src/views/Details/AudioTrackScreen.js b/packages/app/src/views/Details/AudioTrackScreen.js index 29ee06fb2..7e1eadb21 100644 --- a/packages/app/src/views/Details/AudioTrackScreen.js +++ b/packages/app/src/views/Details/AudioTrackScreen.js @@ -5,6 +5,7 @@ import {isMdblistEnabled} from '../../services/mdblistApi'; import {DETAIL_ICON_PATHS} from './detailIcons'; import {SpottableDiv, HorizontalContainer} from './detailsSpottables'; import {PosterBadges} from './DetailBadges'; +import {hidesMediaDescription} from './detailsMedia'; import css from './Details.module.less'; @@ -28,7 +29,7 @@ const AudioTrackScreen = ({item, serverUrl, settings, showsSection, posterUrl, y
- {item.Overview &&

{item.Overview}

} + {item.Overview && !hidesMediaDescription(item, settings) &&

{item.Overview}

} {showsSection('poster') && (
diff --git a/packages/app/src/views/Details/Details.js b/packages/app/src/views/Details/Details.js index 8125ca16b..bbc151420 100644 --- a/packages/app/src/views/Details/Details.js +++ b/packages/app/src/views/Details/Details.js @@ -1273,6 +1273,7 @@ const Details = ({itemId: itemIdProp, initialItem, onPlay, onSelectItem, onSelec {birthPlace}}
- {showsSection('biography') && item.Overview && ( + {showsSection('biography') && !hidesMediaDescription(item?.Type ? item : {...item, Type: 'Person'}, settings) && item.Overview && ( 0 && ( {genres.join(', ')} )} - {item.Overview &&

{item.Overview}

} + {item.Overview && !hidesMediaDescription(item, settings) &&

{item.Overview}

} @@ -272,6 +274,7 @@ PlaylistScreen.propTypes = { playlistMenu: PropTypes.object, posterUrl: PropTypes.string, serverUrl: PropTypes.string, + settings: PropTypes.object, showsSection: PropTypes.func.isRequired, spotlightBackRef: PropTypes.oneOfType([ PropTypes.func, diff --git a/packages/app/src/views/Details/detailsMedia.js b/packages/app/src/views/Details/detailsMedia.js index fa843506f..87d9fbe0d 100644 --- a/packages/app/src/views/Details/detailsMedia.js +++ b/packages/app/src/views/Details/detailsMedia.js @@ -18,12 +18,63 @@ export const COLLECTION_ITEM_TYPES = ['Movie', 'Series', 'Season', 'Episode', 'V export const IDENTIFIABLE_TYPES = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Person', 'MusicAlbum', 'MusicArtist', 'Book', 'Trailer', 'MusicVideo']; -// Whether the summary is being kept back for someone avoiding spoilers. Only a -// film or an episode gives the plot away, so a series or a season keeps its -// description either way. -export const hidesMediaDescription = (item, settings) => - settings?.hideDetailsMediaDescription === true && - (item?.Type === 'Movie' || item?.Type === 'Episode'); +// Whether the summary is being kept back for someone avoiding spoilers or hiding descriptions. +export const hidesMediaDescription = (item, settings) => { + if (!item || !item.Type) return false; + if (settings?.kidsModeEnabled === true) return true; + + const isSeerr = item._seerrMediaId != null || + item._seerrImdbId != null || + item.isSeerr === true || + (typeof item.Id === 'string' && item.Id.startsWith('seerr-')); + const type = item.Type; + + if (isSeerr) { + switch (type) { + case 'Movie': + return Boolean(settings?.hideDetailsMediaDescriptionSeerrMovie); + case 'Series': + case 'Show': + case 'Tv': + return Boolean(settings?.hideDetailsMediaDescriptionSeerrShow); + case 'BoxSet': + case 'Collection': + return Boolean(settings?.hideDetailsMediaDescriptionSeerrCollection); + case 'Person': + return Boolean(settings?.hideDetailsMediaDescriptionPerson); + default: + return false; + } + } + + switch (type) { + case 'Movie': + return Boolean(settings?.hideDetailsMediaDescriptionMovie || settings?.hideDetailsMediaDescription); + case 'Series': + case 'Show': + case 'Tv': + return Boolean(settings?.hideDetailsMediaDescriptionShow); + case 'Season': + return Boolean(settings?.hideDetailsMediaDescriptionSeason); + case 'Episode': + return Boolean(settings?.hideDetailsMediaDescriptionEpisode || settings?.hideDetailsMediaDescription); + case 'BoxSet': + case 'Collection': + return Boolean(settings?.hideDetailsMediaDescriptionBoxSet); + case 'Book': + case 'AudioBook': + return Boolean(settings?.hideDetailsMediaDescriptionBook); + case 'Person': + return Boolean(settings?.hideDetailsMediaDescriptionPerson); + case 'MusicAlbum': + case 'MusicArtist': + case 'Audio': + case 'Playlist': + return Boolean(settings?.hideDetailsMediaDescriptionMusic); + default: + return false; + } +}; // The parent series artwork, for standing in where an episode still or a chapter // frame would give something away. Null when the series offers no artwork, which diff --git a/packages/app/src/views/Details/detailsMedia.test.js b/packages/app/src/views/Details/detailsMedia.test.js index b7f9148dd..737ec3e0e 100644 --- a/packages/app/src/views/Details/detailsMedia.test.js +++ b/packages/app/src/views/Details/detailsMedia.test.js @@ -38,6 +38,55 @@ describe('hidesMediaDescription', () => { expect(hidesMediaDescription({Type: 'Movie'}, off)).toBe(false); expect(hidesMediaDescription({Type: 'Episode'}, off)).toBe(false); }); + + test('local content items are hidden when their specific setting is on', () => { + expect(hidesMediaDescription({Type: 'Movie'}, {hideDetailsMediaDescriptionMovie: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Series'}, {hideDetailsMediaDescriptionShow: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Show'}, {hideDetailsMediaDescriptionShow: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Tv'}, {hideDetailsMediaDescriptionShow: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Season'}, {hideDetailsMediaDescriptionSeason: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Episode'}, {hideDetailsMediaDescriptionEpisode: true})).toBe(true); + expect(hidesMediaDescription({Type: 'BoxSet'}, {hideDetailsMediaDescriptionBoxSet: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Collection'}, {hideDetailsMediaDescriptionBoxSet: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Book'}, {hideDetailsMediaDescriptionBook: true})).toBe(true); + expect(hidesMediaDescription({Type: 'AudioBook'}, {hideDetailsMediaDescriptionBook: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Person'}, {hideDetailsMediaDescriptionPerson: true})).toBe(true); + expect(hidesMediaDescription({Type: 'MusicAlbum'}, {hideDetailsMediaDescriptionMusic: true})).toBe(true); + expect(hidesMediaDescription({Type: 'MusicArtist'}, {hideDetailsMediaDescriptionMusic: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Audio'}, {hideDetailsMediaDescriptionMusic: true})).toBe(true); + expect(hidesMediaDescription({Type: 'Playlist'}, {hideDetailsMediaDescriptionMusic: true})).toBe(true); + }); + + test('seerr content items respect seerr specific settings', () => { + const seerrMovie = {Type: 'Movie', _seerrMediaId: 100}; + const seerrShow = {Type: 'Series', _seerrMediaId: 200}; + const seerrTv = {Type: 'Tv', isSeerr: true}; + const seerrCollection = {Type: 'BoxSet', Id: 'seerr-collection-300'}; + const seerrPerson = {Type: 'Person', _seerrMediaId: 400}; + + expect(hidesMediaDescription(seerrMovie, {hideDetailsMediaDescriptionSeerrMovie: true})).toBe(true); + expect(hidesMediaDescription(seerrMovie, {hideDetailsMediaDescriptionMovie: true})).toBe(false); + + expect(hidesMediaDescription(seerrShow, {hideDetailsMediaDescriptionSeerrShow: true})).toBe(true); + expect(hidesMediaDescription(seerrTv, {hideDetailsMediaDescriptionSeerrShow: true})).toBe(true); + expect(hidesMediaDescription(seerrShow, {hideDetailsMediaDescriptionShow: true})).toBe(false); + + expect(hidesMediaDescription(seerrCollection, {hideDetailsMediaDescriptionSeerrCollection: true})).toBe(true); + expect(hidesMediaDescription(seerrCollection, {hideDetailsMediaDescriptionBoxSet: true})).toBe(false); + + expect(hidesMediaDescription(seerrPerson, {hideDetailsMediaDescriptionPerson: true})).toBe(true); + }); + + test('kids mode forces all media descriptions to be hidden', () => { + const kidsSettings = {kidsModeEnabled: true}; + expect(hidesMediaDescription({Type: 'Movie'}, kidsSettings)).toBe(true); + expect(hidesMediaDescription({Type: 'Series'}, kidsSettings)).toBe(true); + expect(hidesMediaDescription({Type: 'Season'}, kidsSettings)).toBe(true); + expect(hidesMediaDescription({Type: 'Episode'}, kidsSettings)).toBe(true); + expect(hidesMediaDescription({Type: 'BoxSet'}, kidsSettings)).toBe(true); + expect(hidesMediaDescription({Type: 'Person'}, kidsSettings)).toBe(true); + expect(hidesMediaDescription({Type: 'Movie', _seerrMediaId: 1}, kidsSettings)).toBe(true); + }); }); describe('splitCastAndCrew', () => { diff --git a/packages/app/src/views/Details/nouveau/NouveauDetailContent.js b/packages/app/src/views/Details/nouveau/NouveauDetailContent.js index e0c225aa4..d13c56d46 100644 --- a/packages/app/src/views/Details/nouveau/NouveauDetailContent.js +++ b/packages/app/src/views/Details/nouveau/NouveauDetailContent.js @@ -4,7 +4,7 @@ import Spotlight from '@enact/spotlight'; import {getImageUrl} from '../../../utils/helpers'; import {rem, rootScale} from '../../../utils/rootScale'; -import {castPhotoUrl, seriesThumbUrl} from '../detailsMedia'; +import {castPhotoUrl, hidesMediaDescription, seriesThumbUrl} from '../detailsMedia'; import {RowContainer, SpottableDiv} from '../detailsSpottables'; import {spotlightItemImageUrl} from '../spotlight/spotlightImages'; import {nouveauCardImageUrl} from './nouveauCardImage'; @@ -362,11 +362,11 @@ const NouveauDetailContent = (props) => { }, [sections, sortedPlaylist.length, discovery.related.length, discovery.recommendations.length]); const chain = useMemo(() => buildNouveauChain({ - hasOverview: Boolean(item.Overview) && (!isPerson || showsSection('biography')), + hasOverview: Boolean(item.Overview) && !hidesMediaDescription(item, settings) && (!isPerson || showsSection('biography')), hasSeasonSelector: isSeries && seasonNumbers.length > 1, rails: railNodes, footerRows: sections.includes(SECTION_DETAILS) ? 1 : 0 - }), [item.Overview, isPerson, showsSection, isSeries, seasonNumbers.length, railNodes, sections]); + }), [item, settings, isPerson, showsSection, isSeries, seasonNumbers.length, railNodes, sections]); chainRef.current = chain; diff --git a/packages/app/src/views/Person/Person.js b/packages/app/src/views/Person/Person.js index b71b41528..07133987e 100644 --- a/packages/app/src/views/Person/Person.js +++ b/packages/app/src/views/Person/Person.js @@ -11,6 +11,8 @@ import {useUserDataList} from '../../hooks/useUserDataSync'; import {getImageUrl} from '../../utils/helpers'; import {personDateLines, splitFilmography, withPersonRoles} from '../../utils/personCredits'; import {sectionVisibility} from '../../utils/detailSectionLayout'; +import {kidsModeSettings} from '../../utils/kidsMode'; +import {hidesMediaDescription} from '../Details/detailsMedia'; import css from './Person.module.less'; @@ -20,7 +22,8 @@ import css from './Person.module.less'; // overview and tab bar all come from the shared PersonDetailShell. const Person = ({personId, onSelectItem, onSelectSeerrItem, onSelectSeerrPerson}) => { const {api, serverUrl} = useAuth(); - const {settings} = useSettings(); + const {settings: storedSettings} = useSettings(); + const settings = useMemo(() => kidsModeSettings(storedSettings), [storedSettings]); // The detail sections setting reaches this page too, since it stands in for the Details // screen on a person under every style but Nouveau. const showsSection = useMemo(() => sectionVisibility(settings.hiddenDetailSectionsTv), [settings.hiddenDetailSectionsTv]); @@ -161,6 +164,9 @@ const Person = ({personId, onSelectItem, onSelectSeerrItem, onSelectSeerrPerson} actions.push({key: 'seerr', label: $L('Seerr'), icon: null, onClick: handleOpenSeerr}); } + const personItem = person.Type ? person : {...person, Type: 'Person'}; + const hideOverview = !showsSection('biography') || hidesMediaDescription(personItem, settings); + return ( diff --git a/packages/app/src/views/SeerrPerson/SeerrPerson.js b/packages/app/src/views/SeerrPerson/SeerrPerson.js index 599f0fae5..3995578c4 100644 --- a/packages/app/src/views/SeerrPerson/SeerrPerson.js +++ b/packages/app/src/views/SeerrPerson/SeerrPerson.js @@ -9,6 +9,10 @@ import MediaRow from '../../components/MediaRow'; import PersonDetailShell from '../../components/PersonDetailShell'; import {personDateLines, prepareCredits} from '../../utils/personCredits'; import {normalizeMediaItem} from '../../utils/seerrHomeRows'; +import {useSettings} from '../../context/SettingsContext'; +import {kidsModeSettings} from '../../utils/kidsMode'; +import {sectionVisibility} from '../../utils/detailSectionLayout'; +import {hidesMediaDescription} from '../Details/detailsMedia'; import css from './SeerrPerson.module.less'; @@ -21,6 +25,9 @@ const SpottableDiv = Spottable('div'); // Appearances/Crew tabs. const SeerrPerson = ({personId, personName, onClose, onSelectItem, onBack}) => { const {serverUrl} = useAuth(); + const {settings: storedSettings} = useSettings(); + const settings = useMemo(() => kidsModeSettings(storedSettings), [storedSettings]); + const showsSection = useMemo(() => sectionVisibility(settings?.hiddenDetailSectionsTv), [settings?.hiddenDetailSectionsTv]); const [details, setDetails] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -136,6 +143,9 @@ const SeerrPerson = ({personId, personName, onClose, onSelectItem, onBack}) => { const dateLines = personDateLines(details.birthday, details.deathday); const knownFor = details.knownForDepartment ? `${$L('Known for:')} ${details.knownForDepartment}` : null; + const seerrPersonItem = {Type: 'Person', isSeerr: true, ...details}; + const hideOverview = !showsSection('biography') || hidesMediaDescription(seerrPersonItem, settings); + return ( { placeholderInitial={details.name?.[0]} name={personName || details.name} metaLines={[...dateLines, details.placeOfBirth, knownFor].filter(Boolean)} - overview={details.biography} + overview={hideOverview ? null : details.biography} tabs={tabs} /> ); diff --git a/packages/app/src/views/Settings/MediaDescriptionVisibilityView.js b/packages/app/src/views/Settings/MediaDescriptionVisibilityView.js new file mode 100644 index 000000000..56f584e30 --- /dev/null +++ b/packages/app/src/views/Settings/MediaDescriptionVisibilityView.js @@ -0,0 +1,114 @@ +/* eslint-disable react/jsx-no-bind */ +import {Fragment} from 'react'; +import $L from '@enact/i18n/$L'; + +import {SectionTitle, ToggleRow} from './settingsRows'; +import SettingsView from './SettingsView'; + +import css from './Settings.module.less'; + +// Subpage allowing granular control over media description visibility across various local +// and Seerr detail pages. +const MediaDescriptionVisibilityView = ({settings, seerrAvailable, onToggle}) => ( + +
+ {$L('Hide media descriptions from various detail pages.')} +
+ {$L('Local Content')} + onToggle('hideDetailsMediaDescriptionMovie')} + /> + onToggle('hideDetailsMediaDescriptionShow')} + /> + onToggle('hideDetailsMediaDescriptionSeason')} + /> + onToggle('hideDetailsMediaDescriptionEpisode')} + /> + onToggle('hideDetailsMediaDescriptionBoxSet')} + /> + onToggle('hideDetailsMediaDescriptionBook')} + /> + onToggle('hideDetailsMediaDescriptionPerson')} + /> + onToggle('hideDetailsMediaDescriptionMusic')} + /> + {seerrAvailable && ( + + {$L('Seerr Content')} + onToggle('hideDetailsMediaDescriptionSeerrMovie')} + /> + onToggle('hideDetailsMediaDescriptionSeerrShow')} + /> + onToggle('hideDetailsMediaDescriptionSeerrCollection')} + /> + + )} +
+); + +export default MediaDescriptionVisibilityView; diff --git a/packages/app/src/views/Settings/Settings.js b/packages/app/src/views/Settings/Settings.js index b630a62ba..b6f91a980 100644 --- a/packages/app/src/views/Settings/Settings.js +++ b/packages/app/src/views/Settings/Settings.js @@ -64,6 +64,7 @@ import {KidsModeSetView, KidsModeExitView} from './KidsModeViews'; import HomeRowsView from './HomeRowsView'; import ButtonLayoutView from './ButtonLayoutView'; import DetailSectionsView from './DetailSectionsView'; +import MediaDescriptionVisibilityView from './MediaDescriptionVisibilityView'; import DiagnosticsView from './DiagnosticsView'; import LibrariesView from './LibrariesView'; import LibraryOrderView from './LibraryOrderView'; @@ -105,6 +106,7 @@ const FIRST_ROW_VIEWS = { homeRows: 'homerows-view', buttonLayout: 'button-layout-view', detailSections: 'detail-sections-view', + mediaDescriptionVisibility: 'media-description-visibility-view', ratingSources: 'rating-sources-view' }; @@ -750,6 +752,10 @@ const Settings = ({ onBack, onLibrariesChanged, onRunSetupWizard, onSelectItem, pushView({view: 'detailSections', returnFocusTo: 'setting-detailSections'}); }, [pushView]); + const openMediaDescriptionVisibility = useCallback(() => { + pushView({view: 'mediaDescriptionVisibility', returnFocusTo: 'setting-mediaDescriptionVisibility'}); + }, [pushView]); + const hiddenDetailSections = settings[DETAIL_SECTIONS_HIDDEN_KEY]; const toggleDetailSection = useCallback((id) => { updateSetting(DETAIL_SECTIONS_HIDDEN_KEY, toggleHiddenSection(hiddenDetailSections, id)); @@ -1018,6 +1024,7 @@ const Settings = ({ onBack, onLibrariesChanged, onRunSetupWizard, onSelectItem, openOsdButtons, openDetailMetadata, openDetailSections, + openMediaDescriptionVisibility, openDiagnostics, openPinCode, openKidsMode, @@ -1054,7 +1061,7 @@ const Settings = ({ onBack, onLibrariesChanged, onRunSetupWizard, onSelectItem, }), [ settings, capabilities, seerr, achievements, romm, seerrLabel, isSeerr, serverUrl, ratingsResetArmed, resetRatingsSettings, toggleExperimentalTruehd, updateSetting, refreshPlugin, serverVersion, availableThemes, activeThemeId, openThemes, openThemeStore, openHomeRows, - openDetailButtons, openOsdButtons, openDetailMetadata, openDetailSections, openDiagnostics, + openDetailButtons, openOsdButtons, openDetailMetadata, openDetailSections, openMediaDescriptionVisibility, openDiagnostics, openPinCode, openKidsMode, openLibraries, openLibraryOrder, openParentalControls, openSubtitleDownloadLanguages, openTranscodeAudioCodecs, openQrLink, openRatingSources, openRowImageTypes, openExcludedGenres, openMediaBarLibraries, openMediaBarCollections, openScreensaverLibraries, openScreensaverCollections, openScreensaverGenres, openImdbLists, openSeasonalRow, openExternalTmdbLists, openExternalCalendars, @@ -1564,6 +1571,13 @@ const Settings = ({ onBack, onLibrariesChanged, onRunSetupWizard, onSelectItem, onToggle={toggleDetailSection} /> )} + {viewName === 'mediaDescriptionVisibility' && ( + + )} {viewName === 'mediaBarLibraries' && ( $L('Sections'), desc: () => $L('Choose which parts of the Details screen to show'), icon: 'dashboard_customize_outlined', keywords: () => ['sections', 'hide', 'logo', 'tagline', 'cast', 'crew', 'studios', 'chapters', 'extras', 'collections', 'similar', 'more like this', 'episodes', 'media info', 'seerr', 'recommendations', 'biography'], action: (ctx) => ctx.actions.openDetailSections()}, {kind: KIND.TOGGLE, key: 'detailExpandedTabs', label: () => $L('Expanded Tabs'), desc: () => $L('Automatically show tab content while browsing tabs. Turn off to open and close each tab manually.'), icon: 'tab', when: (ctx) => ctx.settings.detailScreenStyle === 'v2'}, {kind: KIND.TOGGLE, key: 'detailShowTechnicalDetails', label: () => $L('Show Technical Details'), desc: () => $L('Show codec, resolution, and stream information in banner summary'), icon: 'info_outline'}, - {kind: KIND.TOGGLE, key: 'hideDetailsMediaDescription', label: () => $L('Hide Media Description'), desc: () => $L('Hide the movie or episode descriptive text.'), icon: 'description_outlined'}, + { + kind: KIND.NAV, + id: 'mediaDescriptionVisibility', + label: () => $L('Toggle Media Description Visibility'), + desc: () => $L('Hide media descriptions from various detail pages.'), + icon: 'description_outlined', + keywords: () => ['hide media description', 'spoilers', 'overview', 'descriptions', 'movie', 'show', 'season', 'episode', 'collection', 'book', 'person', 'music', 'seerr'], + action: (ctx) => ctx.actions.openMediaDescriptionVisibility() + }, {kind: KIND.TOGGLE, key: 'detailUseSeriesThumbnails', label: () => $L('Use Series Thumbnails'), desc: () => $L('Replace thumbnails on the details page with the series thumbnail'), icon: 'image_outlined', when: (ctx) => ['v1', 'v4', 'v5'].indexOf(ctx.settings.detailScreenStyle) >= 0}, {kind: KIND.SECTION, id: 'recommendations', label: () => $L('Recommendations')}, {kind: KIND.OPTION, key: 'recommendationSystemSource', label: () => $L('Recommendation System'), desc: () => $L('Moonfin scores your own library, Jellyfin takes the picks from the server, TMDb uses Seerr, and Hybrid leads with the server then tops up locally'), options: getRecommendationSystemSourceOptions, fallback: () => $L('Moonfin Recommends'), icon: 'auto_awesome'}, From 4b1ce7443c67d339e48a81ce251d4f8cbffee6e2 Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Fri, 16 Oct 2026 03:58:40 -0700 Subject: [PATCH 2/3] Address PR review feedback on settings migration and English string catalog - Register 15 missing English catalog entries in strings.json for MediaDescriptionVisibilityView - Migrate legacy hideDetailsMediaDescription to Movie, Episode, and SeerrMovie on boot and sync - Align Seerr movie description hiding with legacy fallback - Add unit test coverage for profile migration and Seerr legacy fallback --- packages/app/resources/strings.json | 17 +++++++++++- packages/app/src/context/SettingsContext.js | 27 +++++++++++++++++++ packages/app/src/context/settingsSync.test.js | 17 ++++++++++++ .../app/src/views/Details/detailsMedia.js | 2 +- .../src/views/Details/detailsMedia.test.js | 1 + 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/app/resources/strings.json b/packages/app/resources/strings.json index dd0d71445..0acdac021 100644 --- a/packages/app/resources/strings.json +++ b/packages/app/resources/strings.json @@ -2417,5 +2417,20 @@ "{value} (converted from {source})": "{value} (converted from {source})", "{value} (original {original})": "{value} (original {original})", "◀ ▶ Navigate": "◀ ▶ Navigate", - "◀ ▶ to re-order · DEL to remove": "◀ ▶ to re-order · DEL to remove" + "◀ ▶ to re-order · DEL to remove": "◀ ▶ to re-order · DEL to remove", + "Toggle Media Description Visibility": "Toggle Media Description Visibility", + "Hide media descriptions from various detail pages.": "Hide media descriptions from various detail pages.", + "Local Content": "Local Content", + "Hide descriptions on movie detail pages.": "Hide descriptions on movie detail pages.", + "Hide descriptions on TV show detail pages.": "Hide descriptions on TV show detail pages.", + "Hide descriptions on season detail pages.": "Hide descriptions on season detail pages.", + "Hide descriptions on episode detail pages.": "Hide descriptions on episode detail pages.", + "Hide descriptions on box set and collection detail pages.": "Hide descriptions on box set and collection detail pages.", + "Hide descriptions on book and audiobook detail pages.": "Hide descriptions on book and audiobook detail pages.", + "Hide descriptions on person and actor detail pages.": "Hide descriptions on person and actor detail pages.", + "Hide descriptions on album and artist detail pages.": "Hide descriptions on album and artist detail pages.", + "Seerr Content": "Seerr Content", + "Hide descriptions on Seerr movie discovery pages.": "Hide descriptions on Seerr movie discovery pages.", + "Hide descriptions on Seerr TV show discovery pages.": "Hide descriptions on Seerr TV show discovery pages.", + "Hide descriptions on Seerr collection discovery pages.": "Hide descriptions on Seerr collection discovery pages." } diff --git a/packages/app/src/context/SettingsContext.js b/packages/app/src/context/SettingsContext.js index f8c5b89e2..eb28b6fd2 100644 --- a/packages/app/src/context/SettingsContext.js +++ b/packages/app/src/context/SettingsContext.js @@ -358,6 +358,18 @@ export const profileToLocal = (serverProfile) => { if (serverProfile.tmdbApiKey !== undefined && serverProfile.tmdbApiKey !== null) { local.tmdbApiKey = serverProfile.tmdbApiKey; } + if (serverProfile.hideDetailsMediaDescription === true) { + if (local.hideDetailsMediaDescriptionMovie === undefined) { + local.hideDetailsMediaDescriptionMovie = true; + } + if (local.hideDetailsMediaDescriptionEpisode === undefined) { + local.hideDetailsMediaDescriptionEpisode = true; + } + if (local.hideDetailsMediaDescriptionSeerrMovie === undefined) { + local.hideDetailsMediaDescriptionSeerrMovie = true; + } + delete local.hideDetailsMediaDescription; + } return local; }; @@ -790,6 +802,21 @@ export function SettingsProvider({children}) { delete stored.liveTvDirect; migrated = true; } + if ('hideDetailsMediaDescription' in stored) { + if (stored.hideDetailsMediaDescription === true) { + if (stored.hideDetailsMediaDescriptionMovie === undefined) { + stored.hideDetailsMediaDescriptionMovie = true; + } + if (stored.hideDetailsMediaDescriptionEpisode === undefined) { + stored.hideDetailsMediaDescriptionEpisode = true; + } + if (stored.hideDetailsMediaDescriptionSeerrMovie === undefined) { + stored.hideDetailsMediaDescriptionSeerrMovie = true; + } + } + delete stored.hideDetailsMediaDescription; + migrated = true; + } const merged = {...defaultSettings, ...stored}; setSettings(merged); if (migrated) saveToStorage('settings', merged); diff --git a/packages/app/src/context/settingsSync.test.js b/packages/app/src/context/settingsSync.test.js index 647cf1fb6..19a75ace6 100644 --- a/packages/app/src/context/settingsSync.test.js +++ b/packages/app/src/context/settingsSync.test.js @@ -568,6 +568,23 @@ describe('the per row image types', () => { expect(resolveFromEnvelope({}, {homeRowImageTypes: 'genres:thumb'}).homeRowImageTypes).toEqual({genres: 'thumb'}); expect(resolveFromEnvelope({}, {})).not.toHaveProperty('homeRowImageTypes'); }); + + test('migrates legacy hideDetailsMediaDescription to granular movie, episode, and seerr movie settings', () => { + const local = profileToLocal({hideDetailsMediaDescription: true}); + expect(local.hideDetailsMediaDescriptionMovie).toBe(true); + expect(local.hideDetailsMediaDescriptionEpisode).toBe(true); + expect(local.hideDetailsMediaDescriptionSeerrMovie).toBe(true); + expect(local.hideDetailsMediaDescription).toBeUndefined(); + + const partial = profileToLocal({ + hideDetailsMediaDescription: true, + hideDetailsMediaDescriptionMovie: false + }); + expect(partial.hideDetailsMediaDescriptionMovie).toBe(false); + expect(partial.hideDetailsMediaDescriptionEpisode).toBe(true); + expect(partial.hideDetailsMediaDescriptionSeerrMovie).toBe(true); + expect(partial.hideDetailsMediaDescription).toBeUndefined(); + }); }); describe('the profile push queue', () => { diff --git a/packages/app/src/views/Details/detailsMedia.js b/packages/app/src/views/Details/detailsMedia.js index 87d9fbe0d..c9dad3823 100644 --- a/packages/app/src/views/Details/detailsMedia.js +++ b/packages/app/src/views/Details/detailsMedia.js @@ -32,7 +32,7 @@ export const hidesMediaDescription = (item, settings) => { if (isSeerr) { switch (type) { case 'Movie': - return Boolean(settings?.hideDetailsMediaDescriptionSeerrMovie); + return Boolean(settings?.hideDetailsMediaDescriptionSeerrMovie || settings?.hideDetailsMediaDescription); case 'Series': case 'Show': case 'Tv': diff --git a/packages/app/src/views/Details/detailsMedia.test.js b/packages/app/src/views/Details/detailsMedia.test.js index 737ec3e0e..68a16eda4 100644 --- a/packages/app/src/views/Details/detailsMedia.test.js +++ b/packages/app/src/views/Details/detailsMedia.test.js @@ -65,6 +65,7 @@ describe('hidesMediaDescription', () => { const seerrPerson = {Type: 'Person', _seerrMediaId: 400}; expect(hidesMediaDescription(seerrMovie, {hideDetailsMediaDescriptionSeerrMovie: true})).toBe(true); + expect(hidesMediaDescription(seerrMovie, {hideDetailsMediaDescription: true})).toBe(true); expect(hidesMediaDescription(seerrMovie, {hideDetailsMediaDescriptionMovie: true})).toBe(false); expect(hidesMediaDescription(seerrShow, {hideDetailsMediaDescriptionSeerrShow: true})).toBe(true); From 20fbfbf31819f7ead1412f43bf71081583a3434c Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Sat, 24 Oct 2026 11:09:21 -0700 Subject: [PATCH 3/3] fix(settings): migrate both boolean states of legacy hideDetailsMediaDescription and preserve false in layered profiles --- packages/app/src/context/SettingsContext.js | 18 ++++++------ packages/app/src/context/settingsSync.test.js | 28 +++++++++++++++---- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/app/src/context/SettingsContext.js b/packages/app/src/context/SettingsContext.js index eb28b6fd2..6c2c267e0 100644 --- a/packages/app/src/context/SettingsContext.js +++ b/packages/app/src/context/SettingsContext.js @@ -358,15 +358,16 @@ export const profileToLocal = (serverProfile) => { if (serverProfile.tmdbApiKey !== undefined && serverProfile.tmdbApiKey !== null) { local.tmdbApiKey = serverProfile.tmdbApiKey; } - if (serverProfile.hideDetailsMediaDescription === true) { + if (typeof serverProfile.hideDetailsMediaDescription === 'boolean') { + const legacyValue = serverProfile.hideDetailsMediaDescription; if (local.hideDetailsMediaDescriptionMovie === undefined) { - local.hideDetailsMediaDescriptionMovie = true; + local.hideDetailsMediaDescriptionMovie = legacyValue; } if (local.hideDetailsMediaDescriptionEpisode === undefined) { - local.hideDetailsMediaDescriptionEpisode = true; + local.hideDetailsMediaDescriptionEpisode = legacyValue; } if (local.hideDetailsMediaDescriptionSeerrMovie === undefined) { - local.hideDetailsMediaDescriptionSeerrMovie = true; + local.hideDetailsMediaDescriptionSeerrMovie = legacyValue; } delete local.hideDetailsMediaDescription; } @@ -803,15 +804,16 @@ export function SettingsProvider({children}) { migrated = true; } if ('hideDetailsMediaDescription' in stored) { - if (stored.hideDetailsMediaDescription === true) { + if (typeof stored.hideDetailsMediaDescription === 'boolean') { + const legacyValue = stored.hideDetailsMediaDescription; if (stored.hideDetailsMediaDescriptionMovie === undefined) { - stored.hideDetailsMediaDescriptionMovie = true; + stored.hideDetailsMediaDescriptionMovie = legacyValue; } if (stored.hideDetailsMediaDescriptionEpisode === undefined) { - stored.hideDetailsMediaDescriptionEpisode = true; + stored.hideDetailsMediaDescriptionEpisode = legacyValue; } if (stored.hideDetailsMediaDescriptionSeerrMovie === undefined) { - stored.hideDetailsMediaDescriptionSeerrMovie = true; + stored.hideDetailsMediaDescriptionSeerrMovie = legacyValue; } } delete stored.hideDetailsMediaDescription; diff --git a/packages/app/src/context/settingsSync.test.js b/packages/app/src/context/settingsSync.test.js index 19a75ace6..561486c66 100644 --- a/packages/app/src/context/settingsSync.test.js +++ b/packages/app/src/context/settingsSync.test.js @@ -570,11 +570,17 @@ describe('the per row image types', () => { }); test('migrates legacy hideDetailsMediaDescription to granular movie, episode, and seerr movie settings', () => { - const local = profileToLocal({hideDetailsMediaDescription: true}); - expect(local.hideDetailsMediaDescriptionMovie).toBe(true); - expect(local.hideDetailsMediaDescriptionEpisode).toBe(true); - expect(local.hideDetailsMediaDescriptionSeerrMovie).toBe(true); - expect(local.hideDetailsMediaDescription).toBeUndefined(); + const localTrue = profileToLocal({hideDetailsMediaDescription: true}); + expect(localTrue.hideDetailsMediaDescriptionMovie).toBe(true); + expect(localTrue.hideDetailsMediaDescriptionEpisode).toBe(true); + expect(localTrue.hideDetailsMediaDescriptionSeerrMovie).toBe(true); + expect(localTrue.hideDetailsMediaDescription).toBeUndefined(); + + const localFalse = profileToLocal({hideDetailsMediaDescription: false}); + expect(localFalse.hideDetailsMediaDescriptionMovie).toBe(false); + expect(localFalse.hideDetailsMediaDescriptionEpisode).toBe(false); + expect(localFalse.hideDetailsMediaDescriptionSeerrMovie).toBe(false); + expect(localFalse.hideDetailsMediaDescription).toBeUndefined(); const partial = profileToLocal({ hideDetailsMediaDescription: true, @@ -585,6 +591,18 @@ describe('the per row image types', () => { expect(partial.hideDetailsMediaDescriptionSeerrMovie).toBe(true); expect(partial.hideDetailsMediaDescription).toBeUndefined(); }); + + test('preserves explicit false legacy hideDetailsMediaDescription across profile layers', () => { + const envelope = { + tv: {hideDetailsMediaDescription: false}, + global: {hideDetailsMediaDescription: true} + }; + const resolved = resolveFromEnvelope(envelope, null, 'tv'); + expect(resolved.hideDetailsMediaDescriptionMovie).toBe(false); + expect(resolved.hideDetailsMediaDescriptionEpisode).toBe(false); + expect(resolved.hideDetailsMediaDescriptionSeerrMovie).toBe(false); + expect(resolved.hideDetailsMediaDescription).toBeUndefined(); + }); }); describe('the profile push queue', () => {