diff --git a/cypress/e2e/player/autoLogin.cy.ts b/cypress/e2e/player/autoLogin.cy.ts index 5f6c94a98..02ad96d3a 100644 --- a/cypress/e2e/player/autoLogin.cy.ts +++ b/cypress/e2e/player/autoLogin.cy.ts @@ -49,7 +49,6 @@ describe('Auto Login on pseudonimized item', () => { const search = new URLSearchParams({ fullscreen: 'true', }); - const keepSearchString = search.toString(); search.set('username', username); const routeArgs = { rootId: pseudonimizedItem.id, @@ -63,8 +62,11 @@ describe('Auto Login on pseudonimized item', () => { // checks that the user was correctly redirected to the item page const { searchParams: _, ...pathArgs } = routeArgs; cy.location('pathname').should('equal', buildContentPagePath(pathArgs)); - // keep the search params - cy.location('search').should('equal', `?${keepSearchString}`); + cy.location('search').should((searchString) => { + const searchParams = new URLSearchParams(searchString); + expect(searchParams.get('fullscreen')).to.equal('false'); + expect(searchParams.has('username')).to.equal(false); + }); }), ); it('Missing username triggers error', () => { diff --git a/cypress/e2e/player/fullscreen.cy.ts b/cypress/e2e/player/fullscreen.cy.ts new file mode 100644 index 000000000..707a7a944 --- /dev/null +++ b/cypress/e2e/player/fullscreen.cy.ts @@ -0,0 +1,130 @@ +import { PackedFolderItemFactory } from '@graasp/sdk'; + +import { ITEM_FULLSCREEN_BUTTON_ID } from '../../../src/config/selectors'; +import { buildContentPagePath } from './utils'; + +type FullscreenState = { + element: Element | null; +}; + +const visitWithFullscreenApi = (path: string): FullscreenState => { + const fullscreenState: FullscreenState = { element: null }; + + cy.visit(path, { + onBeforeLoad: (win) => { + Object.defineProperty(win.document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenState.element, + }); + Object.defineProperty(win.document.documentElement, 'requestFullscreen', { + configurable: true, + value: cy.stub().callsFake(() => { + fullscreenState.element = win.document.documentElement; + win.document.dispatchEvent(new win.Event('fullscreenchange')); + return Promise.resolve(); + }), + }); + Object.defineProperty(win.document, 'exitFullscreen', { + configurable: true, + value: cy.stub().callsFake(() => { + fullscreenState.element = null; + win.document.dispatchEvent(new win.Event('fullscreenchange')); + return Promise.resolve(); + }), + }); + }, + }); + + return fullscreenState; +}; + +const expectSearchParams = (expected: Record): void => { + cy.location('search').should((search) => { + const searchParams = new URLSearchParams(search); + + Object.entries(expected).forEach(([key, value]) => { + expect(searchParams.get(key)).to.equal(value); + }); + }); +}; + +describe('Fullscreen', () => { + const item = PackedFolderItemFactory({ settings: {} }); + + beforeEach(() => { + cy.setUpApi({ items: [item] }); + }); + + it('Toggles fullscreen from the player and preserves search parameters', () => { + visitWithFullscreenApi( + buildContentPagePath({ + rootId: item.id, + itemId: item.id, + searchParams: 'shuffle=true', + }), + ); + + cy.get(`#${ITEM_FULLSCREEN_BUTTON_ID}`).should('be.visible').click(); + + expectSearchParams({ fullscreen: 'true', shuffle: 'true' }); + cy.window() + .its('document.documentElement.requestFullscreen') + .should('have.been.calledOnce'); + + cy.get(`#${ITEM_FULLSCREEN_BUTTON_ID}`).click(); + + expectSearchParams({ fullscreen: 'false', shuffle: 'true' }); + cy.window().its('document.exitFullscreen').should('have.been.calledOnce'); + }); + + it('Returns to the normal player route after an external fullscreen exit', () => { + const fullscreenState = visitWithFullscreenApi( + buildContentPagePath({ rootId: item.id, itemId: item.id }), + ); + + cy.get(`#${ITEM_FULLSCREEN_BUTTON_ID}`).click(); + expectSearchParams({ fullscreen: 'true' }); + + cy.window().then((win) => { + fullscreenState.element = null; + win.document.dispatchEvent(new win.Event('fullscreenchange')); + }); + + expectSearchParams({ fullscreen: 'false' }); + }); + + it('Uses resize as a fallback when fullscreen exits externally', () => { + const fullscreenState = visitWithFullscreenApi( + buildContentPagePath({ rootId: item.id, itemId: item.id }), + ); + + cy.get(`#${ITEM_FULLSCREEN_BUTTON_ID}`).click(); + expectSearchParams({ fullscreen: 'true' }); + + cy.window().then((win) => { + fullscreenState.element = null; + win.dispatchEvent(new win.Event('resize')); + }); + + expectSearchParams({ fullscreen: 'false' }); + }); + + it('Corrects a fullscreen route when the browser is not fullscreen', () => { + cy.visit( + buildContentPagePath({ + rootId: item.id, + itemId: item.id, + searchParams: 'fullscreen=true', + }), + ); + + expectSearchParams({ fullscreen: 'false' }); + }); + + it('Hides the fullscreen button on mobile', () => { + cy.viewport('iphone-x'); + cy.visit(buildContentPagePath({ rootId: item.id, itemId: item.id })); + + cy.get(`#${ITEM_FULLSCREEN_BUTTON_ID}`).should('not.exist'); + }); +}); diff --git a/cypress/e2e/player/shortcut.cy.ts b/cypress/e2e/player/shortcut.cy.ts index 2d864cbc1..21cf63505 100644 --- a/cypress/e2e/player/shortcut.cy.ts +++ b/cypress/e2e/player/shortcut.cy.ts @@ -132,7 +132,7 @@ describe('Shortcuts', () => { buildContentPagePath({ rootId: parentItem.id, itemId: parentItem.id, - searchParams: 'fullscreen=true', + searchParams: 'shuffle=true', }), ); @@ -143,11 +143,11 @@ describe('Shortcuts', () => { .and('contain', parentItem.id) .and('contain', 'fromName') .and('contain', 'parent+item') - .and('contain', 'fullscreen=true'); + .and('contain', 'shuffle=true'); // go back to origin cy.get(`#${BACK_TO_SHORTCUT_ID}`).click(); - cy.url().should('contain', parentItem.id).and('contain', 'fullscreen=true'); + cy.url().should('contain', parentItem.id).and('contain', 'shuffle=true'); }); it('No from name does not show button', () => { diff --git a/package.json b/package.json index 4b787a967..66fd96d34 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "19.1.1", - "react-fullscreen-crossbrowser": "1.1.3", "react-helmet-async": "2.0.5", "react-hook-form": "7.54.2", "react-i18next": "16.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7371e2c37..69f1a5b2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,9 +152,6 @@ importers: react-dom: specifier: 19.1.1 version: 19.1.1(react@19.1.1) - react-fullscreen-crossbrowser: - specifier: 1.1.3 - version: 1.1.3 react-helmet-async: specifier: 2.0.5 version: 2.0.5(react@19.1.1) @@ -5714,9 +5711,6 @@ packages: react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} - react-fullscreen-crossbrowser@1.1.3: - resolution: {integrity: sha512-z1iOYRnciP+rcgH5t+TDAeFmUmBUAwK5hRkiTg0Rn7VbI0YYcgRRcIVM2hT3fDla7kcT+bXRuIEpZO+54r0P+w==} - react-helmet-async@2.0.5: resolution: {integrity: sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==} peerDependencies: @@ -13126,8 +13120,6 @@ snapshots: react-fast-compare@3.2.2: {} - react-fullscreen-crossbrowser@1.1.3: {} - react-helmet-async@2.0.5(react@19.1.1): dependencies: invariant: 2.2.4 diff --git a/src/modules/player/contexts/LayoutContext.tsx b/src/modules/player/contexts/LayoutContext.tsx index 0bdbce78c..7a4e30aae 100644 --- a/src/modules/player/contexts/LayoutContext.tsx +++ b/src/modules/player/contexts/LayoutContext.tsx @@ -16,8 +16,6 @@ type LayoutContextType = { setIsPinnedOpen: Dispatch>; isChatboxOpen: boolean; setIsChatboxOpen: Dispatch>; - isFullscreen: boolean; - setIsFullscreen: Dispatch>; toggleChatbox: () => void; togglePinned: () => void; }; @@ -31,10 +29,6 @@ const LayoutContext = createContext({ setIsChatboxOpen: () => { throw new Error('No context'); }, - isFullscreen: false, - setIsFullscreen: () => { - throw new Error('No context'); - }, toggleChatbox: () => { throw new Error('No context'); }, @@ -52,7 +46,6 @@ export const LayoutContextProvider = ({ children }: Props): JSX.Element => { const [isPinnedOpen, setIsPinnedOpen] = useState(!isMobile); const [isChatboxOpen, setIsChatboxOpen] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { // TODO: fix this issue @@ -66,8 +59,6 @@ export const LayoutContextProvider = ({ children }: Props): JSX.Element => { setIsPinnedOpen, isChatboxOpen, setIsChatboxOpen, - isFullscreen, - setIsFullscreen, toggleChatbox: () => { setIsChatboxOpen((prev) => !prev); if (isPinnedOpen) { @@ -83,14 +74,7 @@ export const LayoutContextProvider = ({ children }: Props): JSX.Element => { } }, }), - [ - isPinnedOpen, - setIsPinnedOpen, - isChatboxOpen, - setIsChatboxOpen, - isFullscreen, - setIsFullscreen, - ], + [isPinnedOpen, setIsPinnedOpen, isChatboxOpen, setIsChatboxOpen], ); return ( {children} diff --git a/src/modules/player/rightPanel/SideContent.tsx b/src/modules/player/rightPanel/SideContent.tsx index e87a7be31..5c8af33cd 100644 --- a/src/modules/player/rightPanel/SideContent.tsx +++ b/src/modules/player/rightPanel/SideContent.tsx @@ -1,10 +1,9 @@ -import type { JSX } from 'react'; -import Fullscreen from 'react-fullscreen-crossbrowser'; +import { type JSX, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { IconButton, Stack, Tooltip, styled } from '@mui/material'; -import { useParams, useSearch } from '@tanstack/react-router'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { ExpandIcon, ShrinkIcon } from 'lucide-react'; import { Chatbox } from '@/components/chatbox/Chatbox/Chatbox'; @@ -63,48 +62,92 @@ type Props = { }; const SideContent = ({ content, item }: Props): JSX.Element | null => { - const { rootId } = useParams({ from: '/player/$rootId/$itemId' }); + const { itemId, rootId } = useParams({ from: '/player/$rootId/$itemId' }); + const navigate = useNavigate(); const { isMobile } = useMobileView(); const { data: children } = hooks.useChildren(item.id, undefined, { enabled: !!item, }); const search = useSearch({ from: '/player/$rootId/$itemId' }); + const { fullscreen } = search; - const { - toggleChatbox, - togglePinned, - isChatboxOpen, - isPinnedOpen, - isFullscreen, - setIsFullscreen, - } = useLayoutContext(); + const { toggleChatbox, togglePinned, isChatboxOpen, isPinnedOpen } = + useLayoutContext(); const { t } = useTranslation(NS.Player); const settings = item.settings ?? {}; - if (!rootId) { - return null; - } - const pinnedItems = children?.filter( ({ settings: s, hidden }) => s.isPinned && !hidden, ); const pinnedCount = pinnedItems?.length ?? 0; - const toggleFullscreen = () => { - setIsFullscreen(!isFullscreen); + const navigateFullscreen = useCallback( + (fullscreenEnabled: boolean) => { + navigate({ + to: '/player/$rootId/$itemId', + params: { itemId, rootId }, + search: (currentSearch) => ({ + ...currentSearch, + fullscreen: fullscreenEnabled, + }), + }); + }, + [itemId, navigate, rootId], + ); + + const toggleFullscreen = async () => { + try { + if (fullscreen) { + navigateFullscreen(false); + + if (document.fullscreenElement) { + await document.exitFullscreen(); + } + + return; + } + + await document.documentElement.requestFullscreen(); + navigateFullscreen(true); + } catch (error) { + console.error('Error attempting to toggle fullscreen mode:', error); + } }; + useEffect(() => { + const syncFullscreenRoute = () => { + const isBrowserFullscreen = Boolean(document.fullscreenElement); + + if (fullscreen && !isBrowserFullscreen) { + navigateFullscreen(false); + } + }; + + syncFullscreenRoute(); + + document.addEventListener('fullscreenchange', syncFullscreenRoute); + window.addEventListener('resize', syncFullscreenRoute); + + return () => { + document.removeEventListener('fullscreenchange', syncFullscreenRoute); + window.removeEventListener('resize', syncFullscreenRoute); + }; + }, [fullscreen, navigateFullscreen]); + + if (!rootId) { + return null; + } + const displayFullscreenButton = () => { - const { fullscreen } = search; - if (isMobile || !fullscreen) { + if (isMobile) { return null; } return ( { - {isFullscreen ? : } + {fullscreen ? : } ); @@ -163,10 +206,7 @@ const SideContent = ({ content, item }: Props): JSX.Element | null => { }; return ( - setIsFullscreen(isFullscreenEnabled)} - > + <> {displayChatbox()} {displayPinnedItems()} @@ -178,7 +218,7 @@ const SideContent = ({ content, item }: Props): JSX.Element | null => { {content} - + ); };