diff --git a/src/components/Epg/Epg.tsx b/src/components/Epg/Epg.tsx index 843998cb0..16606c7f9 100644 --- a/src/components/Epg/Epg.tsx +++ b/src/components/Epg/Epg.tsx @@ -25,10 +25,9 @@ type Props = { channel: EpgChannel | undefined; program: EpgProgram | undefined; config: Config; - getUrl: (channelId: string) => string; }; -export default function Epg({ channels, onChannelClick, onProgramClick, channel, program, config, getUrl }: Props) { +export default function Epg({ channels, onChannelClick, onProgramClick, channel, program, config }: Props) { const breakpoint = useBreakpoint(); const { t } = useTranslation('common'); @@ -75,7 +74,6 @@ export default function Epg({ channels, onChannelClick, onProgramClick, channel, onScrollToNow(); }} isActive={channel?.id === epgChannel.uuid} - url={getUrl(epgChannel.uuid)} /> )} renderProgram={({ program: programItem, isBaseTimeFormat }) => { @@ -91,7 +89,6 @@ export default function Epg({ channels, onChannelClick, onProgramClick, channel, isActive={program?.id === programItem.data.id} compact={isMobile} isBaseTimeFormat={isBaseTimeFormat} - url={getUrl(programItem.data.channelUuid)} /> ); }} diff --git a/src/components/EpgChannel/EpgChannelItem.module.scss b/src/components/EpgChannel/EpgChannelItem.module.scss index 366e36adb..1105805fe 100644 --- a/src/components/EpgChannel/EpgChannelItem.module.scss +++ b/src/components/EpgChannel/EpgChannelItem.module.scss @@ -5,8 +5,6 @@ .epgChannelBox { position: absolute; padding: 8px 0; - color: inherit; - text-decoration: none; background-color: var(--body-background-color); } diff --git a/src/components/EpgChannel/EpgChannelItem.tsx b/src/components/EpgChannel/EpgChannelItem.tsx index 4d3f4a79d..a2a7c606c 100644 --- a/src/components/EpgChannel/EpgChannelItem.tsx +++ b/src/components/EpgChannel/EpgChannelItem.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type { Channel } from 'planby'; import classNames from 'classnames'; -import { Link } from 'react-router-dom'; import styles from './EpgChannelItem.module.scss'; @@ -14,15 +13,14 @@ type Props = { sidebarWidth: number; onClick?: (channel: Channel) => void; isActive: boolean; - url: string; }; -const EpgChannelItem: React.VFC = ({ channel, channelItemWidth, sidebarWidth, onClick, isActive, url }) => { +const EpgChannelItem: React.VFC = ({ channel, channelItemWidth, sidebarWidth, onClick, isActive }) => { const { position, uuid, channelLogoImage } = channel; const style = { top: position.top, height: position.height, width: sidebarWidth }; return ( - +
= ({ channel, channelItemWidth, sidebarWi > Logo
- +
); }; diff --git a/src/components/EpgProgramItem/EpgProgramItem.module.scss b/src/components/EpgProgramItem/EpgProgramItem.module.scss index e4aa62715..4d3c69e40 100644 --- a/src/components/EpgProgramItem/EpgProgramItem.module.scss +++ b/src/components/EpgProgramItem/EpgProgramItem.module.scss @@ -6,8 +6,6 @@ position: absolute; padding: 8px 4px; overflow: hidden; - color: inherit; - text-decoration: none; } .epgProgram { diff --git a/src/components/EpgProgramItem/EpgProgramItem.tsx b/src/components/EpgProgramItem/EpgProgramItem.tsx index 6c5a64465..28efc53d9 100644 --- a/src/components/EpgProgramItem/EpgProgramItem.tsx +++ b/src/components/EpgProgramItem/EpgProgramItem.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Program, useProgram } from 'planby'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; import styles from './EpgProgramItem.module.scss'; @@ -15,10 +14,9 @@ type Props = { compact: boolean; disabled: boolean; isBaseTimeFormat: boolean; - url: string; }; -const ProgramItem: React.VFC = ({ program, onClick, isActive, compact, disabled, isBaseTimeFormat, url }) => { +const ProgramItem: React.VFC = ({ program, onClick, isActive, compact, disabled, isBaseTimeFormat }) => { const { styles: { position }, formatTime, @@ -41,7 +39,7 @@ const ProgramItem: React.VFC = ({ program, onClick, isActive, compact, di const showLiveTagInImage = !compact && isMinWidth && isLive; return ( - onClick && onClick(program)}> +
onClick && onClick(program)}>
= ({ program, onClick, isActive, compact, di
- + ); }; diff --git a/src/components/Root/Root.tsx b/src/components/Root/Root.tsx index 3ecb3120a..33eff9235 100644 --- a/src/components/Root/Root.tsx +++ b/src/components/Root/Root.tsx @@ -1,7 +1,6 @@ -import React, { FC, useEffect, useMemo } from 'react'; +import React, { FC, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; -import { Navigate, useSearchParams } from 'react-router-dom'; import ErrorPage from '#components/ErrorPage/ErrorPage'; import AccountModal from '#src/containers/AccountModal/AccountModal'; @@ -9,15 +8,14 @@ import { IS_DEMO_MODE, IS_DEVELOPMENT_BUILD, IS_PREVIEW_MODE } from '#src/utils/ import DemoConfigDialog from '#components/DemoConfigDialog/DemoConfigDialog'; import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; import DevConfigSelector from '#components/DevConfigSelector/DevConfigSelector'; -import { cleanupQueryParams, getConfigSource } from '#src/utils/configOverride'; +import { useConfigSource } from '#src/utils/configOverride'; import { loadAndValidateConfig } from '#src/utils/configLoad'; import { initSettings } from '#src/stores/SettingsController'; import AppRoutes from '#src/containers/AppRoutes/AppRoutes'; import registerCustomScreens from '#src/screenMapping'; -import { useAccountStore } from '#src/stores/AccountStore'; -import { useProfileStore } from '#src/stores/ProfileStore'; -const Root: FC = () => { +// This is moved to a separate, parallel component to reduce rerenders +const RootLoader = ({ setAppIsReady }: { setAppIsReady: React.Dispatch> }) => { const { t } = useTranslation('error'); const settingsQuery = useQuery('settings-init', initSettings, { enabled: true, @@ -25,16 +23,7 @@ const Root: FC = () => { refetchInterval: false, }); - const [searchParams, setSearchParams] = useSearchParams(); - - const configSource = useMemo(() => getConfigSource(searchParams, settingsQuery.data), [searchParams, settingsQuery.data]); - - // Update the query string to maintain the right params - useEffect(() => { - if (settingsQuery.data && cleanupQueryParams(searchParams, settingsQuery.data, configSource)) { - setSearchParams(searchParams, { replace: true }); - } - }, [configSource, searchParams, setSearchParams, settingsQuery.data]); + const configSource = useConfigSource(settingsQuery?.data); const configQuery = useQuery('config-init-' + configSource, async () => await loadAndValidateConfig(configSource), { enabled: settingsQuery.isSuccess, @@ -42,22 +31,10 @@ const Root: FC = () => { refetchInterval: false, }); - // Register custom screen mappings + // After the config loads, we can show the rest of the App useEffect(() => { - registerCustomScreens(); - }, []); - - const userData = useAccountStore((s) => ({ loading: s.loading, user: s.user })); - - const { profile, selectingProfileAvatar } = useProfileStore(); - - if (userData.user && selectingProfileAvatar !== null) { - return ; - } - - if (userData.user && !userData.loading && window.location.href.includes('#token')) { - return ; // component instead of hook to prevent extra re-renders - } + setAppIsReady(!configQuery.isError && !configQuery.isLoading && !!configQuery.data); + }, [setAppIsReady, configQuery.isError, configQuery.isLoading, configQuery.data]); const IS_DEMO_OR_PREVIEW = IS_DEMO_MODE || IS_PREVIEW_MODE; @@ -79,7 +56,6 @@ const Root: FC = () => { return ( <> - {!configQuery.isError && !configQuery.isLoading && configQuery.data && } {/*Show the error page when error except in demo mode (the demo mode shows its own error)*/} {configQuery.isError && !IS_DEMO_OR_PREVIEW && ( { /> )} {IS_DEMO_OR_PREVIEW && } - {/* Config select control to improve testing experience */} {(IS_DEVELOPMENT_BUILD || IS_PREVIEW_MODE) && } ); }; +const Root: FC = () => { + const [isReady, setIsReady] = useState(false); + + // Register custom screen mappings + useEffect(() => { + registerCustomScreens(); + }, []); + + return ( + <> + {isReady && } + {isReady && } + {/*This is moved to a separate, parallel component to reduce rerenders*/} + + + ); +}; + export default Root; diff --git a/src/containers/AppRoutes/AppRoutes.tsx b/src/containers/AppRoutes/AppRoutes.tsx index 228dc6416..f1532bd91 100644 --- a/src/containers/AppRoutes/AppRoutes.tsx +++ b/src/containers/AppRoutes/AppRoutes.tsx @@ -21,6 +21,7 @@ import EditProfile from '#src/containers/Profiles/EditProfile'; import useQueryParam from '#src/hooks/useQueryParam'; import { useProfileStore } from '#src/stores/ProfileStore'; import { useProfiles } from '#src/hooks/useProfiles'; +import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; export default function AppRoutes() { const location = useLocation(); @@ -30,9 +31,19 @@ export default function AppRoutes() { const { accessModel } = useConfigStore(({ config, accessModel }) => ({ config, accessModel }), shallow); const user = useAccountStore(({ user }) => user, shallow); - const { profile } = useProfileStore(); + const { profile, selectingProfileAvatar } = useProfileStore(); const { profilesEnabled } = useProfiles(); + const userData = useAccountStore((s) => ({ loading: s.loading, user: s.user })); + + if (userData.user && !userData.loading && window.location.href.includes('#token')) { + return ; // component instead of hook to prevent extra re-renders + } + + if (userData.user && selectingProfileAvatar !== null) { + return ; + } + const shouldManageProfiles = !!user && profilesEnabled && !profile && (accessModel === 'SVOD' || accessModel === 'AUTHVOD') && !userModal && !location.pathname.includes('/u/profiles'); diff --git a/src/pages/ScreenRouting/mediaScreens/MediaLiveChannel/MediaLiveChannel.tsx b/src/pages/ScreenRouting/mediaScreens/MediaLiveChannel/MediaLiveChannel.tsx index 4d009b1ba..1f04e1fda 100644 --- a/src/pages/ScreenRouting/mediaScreens/MediaLiveChannel/MediaLiveChannel.tsx +++ b/src/pages/ScreenRouting/mediaScreens/MediaLiveChannel/MediaLiveChannel.tsx @@ -10,16 +10,15 @@ import Loading from '#src/pages/Loading/Loading'; const MediaLiveChannel: ScreenComponent = ({ data, isLoading }) => { const liveChannelsId = isLiveChannel(data) ? data.liveChannelsId : undefined; - if (!liveChannelsId) { - return ; + if (data && !isLoading && liveChannelsId) { + return ; } - // prevent rendering the Navigate component multiple times when we are loading data - if (isLoading) { - return ; + if (!liveChannelsId) { + return ; } - return ; + return ; }; export default MediaLiveChannel; diff --git a/src/pages/ScreenRouting/playlistScreens/PlaylistLiveChannels/PlaylistLiveChannels.tsx b/src/pages/ScreenRouting/playlistScreens/PlaylistLiveChannels/PlaylistLiveChannels.tsx index 26319f721..ae3232050 100644 --- a/src/pages/ScreenRouting/playlistScreens/PlaylistLiveChannels/PlaylistLiveChannels.tsx +++ b/src/pages/ScreenRouting/playlistScreens/PlaylistLiveChannels/PlaylistLiveChannels.tsx @@ -124,7 +124,7 @@ const PlaylistLiveChannels: ScreenComponent = ({ data: { feedid, playl } // SEO (for channels) - const getUrl = (id: string) => liveChannelsURL(feedid, id); + // const getUrl = (id: string) => liveChannelsURL(feedid, id); const canonicalUrl = `${window.location.origin}${liveChannelsURL(feedid, channel.id)}`; const pageTitle = `${channel.title} - ${siteName}`; @@ -215,7 +215,6 @@ const PlaylistLiveChannels: ScreenComponent = ({ data: { feedid, playl channel={channel} program={program} config={config} - getUrl={getUrl} /> diff --git a/src/utils/configOverride.ts b/src/utils/configOverride.ts index cefa4b71e..d3aff2d29 100644 --- a/src/utils/configOverride.ts +++ b/src/utils/configOverride.ts @@ -1,4 +1,6 @@ import type { NavigateFunction } from 'react-router/dist/lib/hooks'; +import { useLayoutEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { logDev } from '#src/utils/common'; import type { Settings } from '#src/stores/SettingsStore'; @@ -23,7 +25,7 @@ export function getConfigNavigateCallback(navigate: NavigateFunction) { }; } -export function getConfigSource(searchParams: URLSearchParams, settings: Settings | undefined) { +function getConfigSource(configKey: string | null, settings: Settings | undefined) { if (!settings) { return ''; } @@ -33,22 +35,20 @@ export function getConfigSource(searchParams: URLSearchParams, settings: Setting return settings.defaultConfigSource; } - const configQueryParam = searchParams.get(configQueryKey) ?? searchParams.get(configLegacyQueryKey); - - if (configQueryParam !== null) { + if (configKey !== null) { // If the query param exists but the value is empty, clear the storage and allow fallback to the default config - if (!configQueryParam) { + if (!configKey) { storage.removeItem(configFileStorageKey); return settings.defaultConfigSource; } // If it's valid, store it and return it - if (isValidConfigSource(configQueryParam, settings)) { - storage.setItem(configFileStorageKey, configQueryParam); - return configQueryParam; + if (isValidConfigSource(configKey, settings)) { + storage.setItem(configFileStorageKey, configKey); + return configKey; } - logDev(`Invalid app-config query param: ${configQueryParam}`); + logDev(`Invalid app-config query param: ${configKey}`); } // Yes this falls through from above to look up the stored value if the query string is invalid and that's OK @@ -67,28 +67,54 @@ export function getConfigSource(searchParams: URLSearchParams, settings: Setting return settings.defaultConfigSource; } -export function cleanupQueryParams(searchParams: URLSearchParams, settings: Settings, configSource: string | undefined) { - let anyTouched = false; +export function useConfigSource(settings?: Settings) { + const [searchParams, setSearchParams] = useSearchParams(); - // Remove the old ?c= param - if (searchParams.has(configLegacyQueryKey)) { - searchParams.delete(configLegacyQueryKey); - anyTouched = true; - } + const configKey = searchParams.get(configQueryKey) ?? searchParams.get(configLegacyQueryKey); + const configSource = useMemo(() => getConfigSource(configKey, settings), [configKey, settings]); - // If there is no valid config source or the config source equals the default, remove the ?app-config= param - if (searchParams.has(configQueryKey) && (!configSource || configSource === settings?.defaultConfigSource)) { - searchParams.delete(configQueryKey); - anyTouched = true; - } + // Update the query string to maintain the right params + useLayoutEffect(() => { + if (!settings) { + return; + } - // If the config source is not the default and the query string isn't set right, set the ?app-config= param - if (configSource && configSource !== settings?.defaultConfigSource && searchParams.get(configQueryKey) !== configSource) { - searchParams.set(configQueryKey, configSource); - anyTouched = true; - } + // Remove the old ?c= param + if (searchParams.has(configLegacyQueryKey)) { + setSearchParams( + (s) => { + s.delete(configLegacyQueryKey); + return s; + }, + { replace: true }, + ); + } + + // If there is no valid config source or the config source equals the default, remove the ?app-config= param + if (searchParams.has(configQueryKey) && (!configSource || configSource === settings?.defaultConfigSource)) { + setSearchParams( + (s) => { + s.delete(configQueryKey); + + return s; + }, + { replace: true }, + ); + } + + // If the config source is not the default and the query string isn't set right, set the ?app-config= param + if (configSource && configSource !== settings?.defaultConfigSource && searchParams.get(configQueryKey) !== configSource) { + setSearchParams( + (s) => { + s.set(configQueryKey, configSource); + return s; + }, + { replace: true }, + ); + } + }, [configSource, searchParams, setSearchParams, configQueryKey, configLegacyQueryKey, configSource, settings]); - return anyTouched; + return configSource; } function isValidConfigSource(source: string, settings: Settings) { diff --git a/test-e2e/tests/live_channel_test.ts b/test-e2e/tests/live_channel_test.ts index 1c0992b28..c8d9ca0fe 100644 --- a/test-e2e/tests/live_channel_test.ts +++ b/test-e2e/tests/live_channel_test.ts @@ -108,7 +108,7 @@ Scenario('I see the epg on the live channel screen', async ({ I }) => { I.see('Start watching'); I.seeElement(channel1LiveProgramLocator); - await isSelectedProgram(I, channel1LiveProgramLocator, 'channel 1'); + await isSelectedProgram(I, channel1LiveProgramLocator, 'channel 1', true); I.seeElement(channel1PreviousProgramLocator); await isProgram(I, channel1PreviousProgramLocator, 'channel 1'); @@ -125,7 +125,7 @@ Scenario('I see the epg on the live channel screen', async ({ I }) => { Scenario('I can select an upcoming program on the same channel', async ({ I }) => { await I.openVideoCard('Channel 1'); I.seeElement(channel1LiveProgramLocator); - await isSelectedProgram(I, channel1LiveProgramLocator, 'channel 1'); + await isSelectedProgram(I, channel1LiveProgramLocator, 'channel 1', true); I.seeElement(channel1UpcomingProgramLocator); await isProgram(I, channel1UpcomingProgramLocator, 'channel 1'); @@ -134,7 +134,7 @@ Scenario('I can select an upcoming program on the same channel', async ({ I }) = waitForEpgAnimation(I); I.scrollTo(channel1UpcomingProgramLocator); - await isSelectedProgram(I, channel1UpcomingProgramLocator, 'channel 1'); + await isSelectedProgram(I, channel1UpcomingProgramLocator, 'channel 1', false); I.see('The Flash', locate('div').inside(videoDetailsLocator)); @@ -150,7 +150,7 @@ Scenario('I can select an upcoming program on the same channel', async ({ I }) = Scenario('I can select a previous program on the same channel and watch the video', async ({ I }) => { await I.openVideoCard('Channel 1'); I.seeElement(channel1LiveProgramLocator); - await isSelectedProgram(I, channel1LiveProgramLocator, 'channel 1'); + await isSelectedProgram(I, channel1LiveProgramLocator, 'channel 1', true); I.seeElement(channel1PreviousProgramLocator); await isProgram(I, channel1PreviousProgramLocator, 'channel 1'); @@ -159,8 +159,7 @@ Scenario('I can select a previous program on the same channel and watch the vide waitForEpgAnimation(I); - I.scrollTo(channel1PreviousProgramLocator); - await isSelectedProgram(I, channel1PreviousProgramLocator, 'channel 1'); + await isSelectedProgram(I, channel1PreviousProgramLocator, 'channel 1', false); I.dontSee('LIVE', locate('div').inside(videoDetailsLocator)); I.see('On Channel 1', locate('div').inside(videoDetailsLocator)); @@ -188,7 +187,7 @@ Scenario('I can select a program on another channel', async ({ I }) => { I.scrollTo(channel2LiveProgramLocator); I.seeElement(channel2LiveProgramLocator); - await isSelectedProgram(I, channel2LiveProgramLocator, 'channel 2'); + await isSelectedProgram(I, channel2LiveProgramLocator, 'channel 2', true); I.click(channel1Locator); waitForEpgAnimation(I); @@ -220,7 +219,7 @@ Scenario('I can navigate through the epg', async ({ I }) => { waitForEpgAnimation(I); I.scrollTo(channel1LiveProgramLocator); I.seeElement(channel1LiveProgramLocator); - await isSelectedProgram(I, channel1LiveProgramLocator, 'channel 1'); + await isSelectedProgram(I, channel1LiveProgramLocator, 'channel 1', true); I.seeElement(channel2LiveProgramLocator); I.scrollTo(channel2LiveProgramLocator); @@ -247,12 +246,15 @@ Scenario('I can see the background image for Channel 4', async ({ I }) => { await I.seeVideoDetailsBackgroundImage('Channel 4', 'https://cdn.jwplayer.com/v2/media/kH7LozaK/images/background.webp?poster_fallback=1&width=1280'); }); -async function isSelectedProgram(I: CodeceptJS.I, locator: CodeceptJS.Locator, channel: string) { +async function isSelectedProgram(I: CodeceptJS.I, locator: CodeceptJS.Locator, channel: string, isLive: boolean) { + I.moveCursorTo('body', 0, 0); // This prevents accidentally triggering the hover state + await checkStyle(I, locator, { 'background-color': programSelectedBackgroundColor, - border: programLiveBorder, + border: isLive ? programLiveBorder : programBorder, }); - await I.say(`I see the program is selected on ${channel}`); + + I.say(`I see the program is selected on ${channel}`); } async function isLiveProgram(I: CodeceptJS.I, locator: CodeceptJS.Locator, channel: string) { @@ -260,7 +262,8 @@ async function isLiveProgram(I: CodeceptJS.I, locator: CodeceptJS.Locator, chann 'background-color': programBackgroundColor, border: programLiveBorder, }); - await I.say(`I see the program is live on ${channel}`); + + I.say(`I see the program is live on ${channel}`); } async function isProgram(I: CodeceptJS.I, locator: CodeceptJS.Locator, channel: string) { @@ -268,7 +271,8 @@ async function isProgram(I: CodeceptJS.I, locator: CodeceptJS.Locator, channel: 'background-color': programBackgroundColor, border: programBorder, }); - await I.say(`I see the program is not active nor selected on ${channel}`); + + I.say(`I see the program is not active nor selected on ${channel}`); } async function checkStyle(I: CodeceptJS.I, locator: CodeceptJS.LocatorOrString, styles: Record) { @@ -276,5 +280,6 @@ async function checkStyle(I: CodeceptJS.I, locator: CodeceptJS.LocatorOrString, } function waitForEpgAnimation(I: CodeceptJS.I, sec: number = 1) { + I.waitForLoaderDone(); return I.wait(sec); }