diff --git a/packages/app/src/context/SettingsContext.js b/packages/app/src/context/SettingsContext.js index f95d07e..df609e4 100644 --- a/packages/app/src/context/SettingsContext.js +++ b/packages/app/src/context/SettingsContext.js @@ -113,6 +113,11 @@ const VALUE_CONVERSIONS = { toServer: v => v ? 'always' : 'never', fromServer: v => v !== 'never' }, + // The other clients offer off, logo or library, where this app keeps a separate toggle for + // turning it off. There is nothing to draw for "off", so leave the mode we already have. + screensaverMode: { + fromServer: v => (v === 'off' ? undefined : v) + }, // Three states here against a boolean elsewhere. "Per Library" has no equivalent, so it // declines to push and leaves whatever the server holds. folderViewMode: { @@ -137,7 +142,7 @@ const VALUE_CONVERSIONS = { // move together, so it gets resolved whole rather than a key at a time. }; -const SYNCABLE_KEYS = [ +export const SYNCABLE_KEYS = [ 'showShuffleButton', 'shuffleContentType', 'showGenresButton', 'showFavoritesButton', 'showLibrariesInToolbar', 'mergeContinueWatchingNextUp', 'nextUpMaxDays', @@ -177,18 +182,24 @@ const SYNCABLE_KEYS = [ 'diagnosticLoggingEnabled', 'uiLanguage', 'blockedRatings', - 'customHomeRows', 'mergeRadarrSonarrCalendars', 'radarrCalendarShowCinema', 'radarrCalendarShowDigital', 'radarrCalendarShowPhysical', 'radarrCalendarShowDate', 'sonarrCalendarShowDate', 'sonarrCalendarShowEpisodeInfo', 'showSeerrButton', + 'screensaverMode', + // Settings this app has no screen for. They ride along so a value set on another client + // survives the profile the TV writes back. + 'showCastButton', 'themeMusicLoop', + 'classicHomeRowsPadding', 'modernHomeRowsPadding', + 'detailShowTechnicalDetails', + 'recommendationSystemSource', 'recommendationsApplyParentalRatingCap', 'detailButtonOrderTv', 'hiddenDetailButtonsTv', 'osdButtonOrderTv', 'hiddenOsdButtonsTv', 'focusBorderColor', 'navbarOpacity', 'navbarColor', ]; -const profileToLocal = (serverProfile) => { +export const profileToLocal = (serverProfile) => { if (!serverProfile) return {}; const local = {}; for (const [key, value] of Object.entries(serverProfile)) { @@ -211,7 +222,7 @@ const profileToLocal = (serverProfile) => { return local; }; -const localToProfile = (localSettings) => { +export const localToProfile = (localSettings) => { const profile = {}; for (const key of SYNCABLE_KEYS) { if (key === 'homeRows') continue; diff --git a/packages/app/src/context/defaultSettings.js b/packages/app/src/context/defaultSettings.js index 4ed312a..3239528 100644 --- a/packages/app/src/context/defaultSettings.js +++ b/packages/app/src/context/defaultSettings.js @@ -188,6 +188,15 @@ export const defaultSettings = { truehdPassthrough: true, blockedRatings: [], showSeerrButton: true, + // Synced but not shown anywhere in this app, so null keeps the TV from stamping a default + // of its own over what another client set. The padding pair is a pixel count on the server. + themeMusicLoop: null, + showCastButton: null, + classicHomeRowsPadding: null, + modernHomeRowsPadding: null, + detailShowTechnicalDetails: null, + recommendationSystemSource: null, + recommendationsApplyParentalRatingCap: null, performanceMode: 'auto', focusBorderColor: '', navbarOpacity: 100, diff --git a/packages/app/src/context/settingsSync.test.js b/packages/app/src/context/settingsSync.test.js new file mode 100644 index 0000000..119c86d --- /dev/null +++ b/packages/app/src/context/settingsSync.test.js @@ -0,0 +1,84 @@ +// The server profile is typed, so a key this app spells differently isn't refused, it's +// quietly dropped. Nothing on this side can prove a field name is right, but these lock down +// what gets sent and what gets taken. + +// Nothing renders here. The provider only needs React to exist while the module loads, and +// the real one can't be pulled in because the CLI ships a second copy that disagrees with it. +jest.mock('react/jsx-dev-runtime', () => ({})); +jest.mock('react', () => ({createContext: () => ({})})); +// Storage picks its platform module through a dynamic import that jest can't transform. +jest.mock('../services/storage', () => ({})); + +import {SYNCABLE_KEYS, defaultSettings, profileToLocal, localToProfile} from './SettingsContext'; + +describe('profileToLocal', () => { + test('takes the TV button fields under their own names', () => { + const local = profileToLocal({ + detailButtonOrderTv: ['play', 'trailer'], + hiddenDetailButtonsTv: ['shuffle'], + osdButtonOrderTv: ['subtitles'], + hiddenOsdButtonsTv: ['audio'] + }); + + expect(local.detailButtonOrderTv).toEqual(['play', 'trailer']); + expect(local.hiddenDetailButtonsTv).toEqual(['shuffle']); + expect(local.osdButtonOrderTv).toEqual(['subtitles']); + expect(local.hiddenOsdButtonsTv).toEqual(['audio']); + }); + + test('leaves the desktop and mobile button fields alone', () => { + const local = profileToLocal({ + osdButtonOrderDesktop: ['desktop-order'], + hiddenDetailButtonsMobile: ['mobile-hidden'] + }); + + expect(local).toEqual({}); + }); + + test('ignores a screensaver mode this app has no way to draw', () => { + expect(profileToLocal({screensaverMode: 'off'}).screensaverMode).toBeUndefined(); + expect(profileToLocal({screensaverMode: 'logo'}).screensaverMode).toBe('logo'); + }); +}); + +describe('localToProfile', () => { + test('says nothing about settings this app has no screen for', () => { + const profile = localToProfile(defaultSettings); + + for (const key of ['showCastButton', 'themeMusicLoop', 'classicHomeRowsPadding', + 'modernHomeRowsPadding', 'detailShowTechnicalDetails', 'recommendationSystemSource', + 'recommendationsApplyParentalRatingCap']) { + expect(profile).not.toHaveProperty(key); + } + }); + + test('writes those settings back once the server has supplied one', () => { + const profile = localToProfile({...defaultSettings, ...profileToLocal({ + showCastButton: false, + classicHomeRowsPadding: 12 + })}); + + expect(profile.showCastButton).toBe(false); + expect(profile.classicHomeRowsPadding).toBe(12); + }); + + test('keeps the local only home row list out of the profile', () => { + const profile = localToProfile({...defaultSettings, customHomeRows: [{id: 'row'}]}); + + expect(profile).not.toHaveProperty('customHomeRows'); + }); + + // Some synced keys have no default at all, which is how a screen asks for its built in + // order rather than a stored one. + test('invents nothing when there is no local value to send', () => { + expect(localToProfile({})).toEqual({}); + }); +}); + +describe('SYNCABLE_KEYS', () => { + test('no key is listed twice', () => { + const repeated = SYNCABLE_KEYS.filter((key, i) => SYNCABLE_KEYS.indexOf(key) !== i); + + expect(repeated).toEqual([]); + }); +});