Skip to content

Commit 75cff0c

Browse files
authored
Merge pull request Sofie-Automation#1653 from rjmunro/rjmunro/improve-screens-fullscreen-mode
Improve screens fullscreen mode
2 parents af0e922 + c9f5957 commit 75cff0c

10 files changed

Lines changed: 201 additions & 48 deletions

File tree

packages/documentation/docs/user-guide/features/sofie-views-and-screens.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,7 @@ The Configurable Screens section uses collapsible accordion panels that let you
254254
**Camera Screen Configuration**
255255
- Filter by specific Source Layer IDs (e.g., cameras, DVEs)
256256
- Filter by Studio Labels to show only relevant cameras
257-
- Enable fullscreen mode for mobile devices
258-
- Generates URL with `sourceLayerIds`, `studioLabels`, and `fullscreen` parameters
257+
- Generates URL with `sourceLayerIds` and `studioLabels` parameters
259258

260259
**Prompter Configuration**
261260
- Configure display options (mirroring, font size, margins, read marker position)
@@ -271,6 +270,8 @@ Bookmark the "Available screens" view for your studio (e.g., `/countdowns/studio
271270

272271
## Sofie Screens
273272

273+
All Screens support a `?fullscreen=1` query parameter. When this parameter is present, the screen will display a semi-transparent overlay prompting the user to click anywhere to enter fullscreen mode. Once fullscreen is entered, the overlay disappears. If the user exits fullscreen, the overlay will reappear.
274+
274275
### Prompter Screen
275276

276277
`/prompter/:studioId`
@@ -335,7 +336,6 @@ This screen can be configured using query parameters:
335336
| :--------------- | :----- | :--------------------------------------------------------------------------------------------------------- | :----------- |
336337
| `sourceLayerIds` | string | A comma-separated list of Source Layer IDs to be considered for display | _(show all)_ |
337338
| `studioLabels` | string | A comma-separated list of Studio Labels (Piece `.content.studioLabel` values) to be considered for display | _(show all)_ |
338-
| `fullscreen` | 0 / 1 | Should the screen be shown fullscreen on the device on first user interaction | 0 |
339339

340340
Example: [http://127.0.0.1/countdowns/studio0/camera?sourceLayerIds=camera0,dve0&studioLabels=1,KAM%201,K1,KAM1&fullscreen=1](http://127.0.0.1/countdowns/studio0/camera?sourceLayerIds=camera0,dve0&studioLabels=1,KAM%201,K1,KAM1&fullscreen=1)
341341

packages/webui/src/client/ui/App.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { RundownList } from './RundownList.js'
2929
import { RundownView } from './RundownView.js'
3030
import { ActiveRundownView } from './ActiveRundownView.js'
3131
import { ClockView } from './ClockView/ClockView.js'
32+
import { FullscreenOverlay } from './ClockView/FullscreenOverlay.js'
3233
import { ConnectionStatusNotification } from '../lib/ConnectionStatusNotification.js'
3334
import { BrowserRouter as Router, Route, Switch, Redirect } from 'react-router-dom'
3435
import { ErrorBoundary } from '../lib/ErrorBoundary.js'
@@ -232,6 +233,14 @@ export const App: React.FC = function App() {
232233
<Route path="/" component={ConnectionStatusNotification} />
233234
</Switch>
234235
</ErrorBoundary>
236+
<ErrorBoundary>
237+
<Switch>
238+
{/* Screens that should show the fullscreen overlay prompt */}
239+
<Route path="/countdowns/:studioId/:page" component={FullscreenOverlay} />
240+
<Route path="/prompter/:studioId" component={FullscreenOverlay} />
241+
<Route path="/" component={NullComponent} />
242+
</Switch>
243+
</ErrorBoundary>
235244
<ErrorBoundary>
236245
<DocumentTitleProvider />
237246
</ErrorBoundary>

packages/webui/src/client/ui/ClockView/CameraConfigForm.tsx

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,18 @@ import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub'
88
import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData.js'
99
import { ShowStyleBases } from '../../collections/index.js'
1010
import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides'
11+
import { FullscreenLink } from './FullscreenLink.js'
1112

1213
import './PrompterConfigForm.scss'
1314

1415
interface CameraConfigState {
1516
selectedSourceLayerIds: Set<string>
1617
studioLabels: string
17-
fullscreen: boolean
1818
}
1919

2020
const initialState: CameraConfigState = {
2121
selectedSourceLayerIds: new Set(),
2222
studioLabels: '',
23-
fullscreen: false,
2423
}
2524

2625
/** Source layer types that are relevant for the camera screen */
@@ -36,9 +35,6 @@ function generateCameraUrl(studioId: StudioId, config: CameraConfigState): strin
3635
if (config.studioLabels.trim()) {
3736
params.set('studioLabels', config.studioLabels.trim())
3837
}
39-
if (config.fullscreen) {
40-
params.set('fullscreen', '1')
41-
}
4238

4339
const queryString = params.toString()
4440
return `/countdowns/${studioId}/camera${queryString ? '?' + queryString : ''}`
@@ -157,17 +153,6 @@ export function CameraConfigForm({ studioId }: Readonly<{ studioId: StudioId }>)
157153
{t('Comma-separated list of studio labels to filter by. Leave empty for all.')}
158154
</Form.Text>
159155
</Form.Group>
160-
161-
<Form.Group className="mb-2">
162-
<Form.Check
163-
type="checkbox"
164-
id="camera-fullscreen"
165-
label={t('Fullscreen mode')}
166-
checked={config.fullscreen}
167-
onChange={(e) => updateConfig('fullscreen', e.target.checked)}
168-
/>
169-
<Form.Text className="text-muted">{t('Click anywhere on the screen to enter fullscreen.')}</Form.Text>
170-
</Form.Group>
171156
</div>
172157

173158
{/* Generated URL and Open Button */}
@@ -178,9 +163,12 @@ export function CameraConfigForm({ studioId }: Readonly<{ studioId: StudioId }>)
178163
</Form.Label>
179164
<Form.Control type="text" size="sm" readOnly value={generatedUrl} onClick={(e) => e.currentTarget.select()} />
180165
</Form.Group>
181-
<Link to={generatedUrl} className="btn btn-primary">
166+
<Link to={generatedUrl} className="btn btn-primary me-2">
182167
{t('Open Camera Screen')}
183168
</Link>
169+
<FullscreenLink to={generatedUrl} className="btn btn-secondary">
170+
{t('Open Fullscreen')}
171+
</FullscreenLink>
184172
</div>
185173
</div>
186174
)

packages/webui/src/client/ui/ClockView/CameraScreen/index.tsx

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { useTranslation } from 'react-i18next'
2626
import { Spinner } from '../../../lib/Spinner.js'
2727
import { useBlackBrowserTheme } from '../../../lib/useBlackBrowserTheme.js'
2828
import { useWakeLock } from './useWakeLock.js'
29-
import { catchError, useDebounce } from '../../../lib/lib.js'
29+
import { useDebounce } from '../../../lib/lib.js'
3030
import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub'
3131
import { useSetDocumentClass, useSetDocumentDarkTheme } from '../../util/useSetDocumentClass.js'
3232

@@ -54,14 +54,12 @@ export const CanvasSizeContext = React.createContext<number>(1)
5454

5555
const PARAM_NAME_SOURCE_LAYER_IDS = 'sourceLayerIds'
5656
const PARAM_NAME_STUDIO_LABEL = 'studioLabels'
57-
const PARAM_NAME_FULLSCREEN = 'fullscreen'
5857

5958
export function CameraScreen({ playlist, studioId }: Readonly<IProps>): JSX.Element | null {
6059
const playlistIds = playlist ? [playlist._id] : []
6160

6261
const [studioLabels, setStudioLabels] = useState<string[] | null>(null)
6362
const [sourceLayerIds, setSourceLayerIds] = useState<string[] | null>(null)
64-
const [fullScreenMode, setFullScreenMode] = useState<boolean>(false)
6563

6664
useBlackBrowserTheme()
6765

@@ -73,7 +71,6 @@ export function CameraScreen({ playlist, studioId }: Readonly<IProps>): JSX.Elem
7371

7472
const studioLabelParam = queryParams[PARAM_NAME_STUDIO_LABEL] ?? null
7573
const sourceLayerTypeParam = queryParams[PARAM_NAME_SOURCE_LAYER_IDS] ?? null
76-
const fullscreenParam = queryParams[PARAM_NAME_FULLSCREEN] ?? false
7774

7875
setStudioLabels(
7976
Array.isArray(studioLabelParam) ? studioLabelParam : studioLabelParam === null ? null : [studioLabelParam]
@@ -85,7 +82,6 @@ export function CameraScreen({ playlist, studioId }: Readonly<IProps>): JSX.Elem
8582
? null
8683
: [sourceLayerTypeParam]
8784
)
88-
setFullScreenMode(Array.isArray(fullscreenParam) ? fullscreenParam[0] === '1' : fullscreenParam === '1')
8985
}, [location.search])
9086

9187
const rundowns = useTracker(
@@ -217,27 +213,6 @@ export function CameraScreen({ playlist, studioId }: Readonly<IProps>): JSX.Elem
217213
}
218214
}, [canvasElRef.current])
219215

220-
useLayoutEffect(() => {
221-
if (!document.fullscreenEnabled || !fullScreenMode) return
222-
223-
const targetEl = document.documentElement
224-
225-
function onCanvasClick() {
226-
if (document.fullscreenElement !== null) return
227-
targetEl
228-
?.requestFullscreen({
229-
navigationUI: 'hide',
230-
})
231-
.catch(catchError('targetEl.requestFullscreen'))
232-
}
233-
234-
document.documentElement.addEventListener('click', onCanvasClick)
235-
236-
return () => {
237-
document.documentElement.removeEventListener('click', onCanvasClick)
238-
}
239-
}, [fullScreenMode])
240-
241216
useWakeLock()
242217

243218
if (!studio && studioReady) return <h1 className="m-4 text-center">{t("This studio doesn't exist.")}</h1>

packages/webui/src/client/ui/ClockView/ClockViewIndex.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Accordion from 'react-bootstrap/esm/Accordion'
77
import { PresenterConfigForm } from './PresenterConfigForm'
88
import { CameraConfigForm } from './CameraConfigForm'
99
import { PrompterConfigForm } from './PrompterConfigForm'
10+
import { FullscreenLink } from './FullscreenLink'
1011

1112
type AccordionKey = 'presenter' | 'camera' | 'prompter'
1213

@@ -38,12 +39,21 @@ export function ClockViewIndex({ studioId }: Readonly<{ studioId: StudioId }>):
3839
<ul>
3940
<li>
4041
<Link to={`/countdowns/${studioId}/director`}>{t('Director Screen')}</Link>
42+
{' ('}
43+
<FullscreenLink to={`/countdowns/${studioId}/director`}>{t('fullscreen')}</FullscreenLink>
44+
{')'}
4145
</li>
4246
<li>
4347
<Link to={`/countdowns/${studioId}/overlay`}>{t('Overlay Screen')}</Link>
48+
{' ('}
49+
<FullscreenLink to={`/countdowns/${studioId}/overlay`}>{t('fullscreen')}</FullscreenLink>
50+
{')'}
4451
</li>
4552
<li>
4653
<Link to={`/countdowns/${studioId}/multiview`}>{t('All Screens in a MultiViewer')}</Link>
54+
{' ('}
55+
<FullscreenLink to={`/countdowns/${studioId}/multiview`}>{t('fullscreen')}</FullscreenLink>
56+
{')'}
4757
</li>
4858
<li>
4959
<Link to={`/activeRundown/${studioId}`}>{t('Active Rundown View')}</Link>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useCallback, MouseEvent } from 'react'
2+
import { Link, useHistory } from 'react-router-dom'
3+
import { catchError } from '../../lib/lib.js'
4+
5+
interface FullscreenLinkProps {
6+
to: string
7+
children: React.ReactNode
8+
className?: string
9+
}
10+
11+
/**
12+
* Appends fullscreen=1 to a URL path, handling existing query strings.
13+
*/
14+
function addFullscreenParam(url: string): string {
15+
const hasQuery = url.includes('?')
16+
return hasQuery ? `${url}&fullscreen=1` : `${url}?fullscreen=1`
17+
}
18+
19+
/**
20+
* A link that navigates to a destination and also triggers fullscreen mode.
21+
* Regular clicks will navigate AND trigger fullscreen.
22+
* Cmd-click, Ctrl-click, or middle-click will open in a new tab (normal link behavior).
23+
* The URL will include ?fullscreen=1 so the FullscreenOverlay can prompt for fullscreen if needed.
24+
*/
25+
export function FullscreenLink({ to, children, className }: Readonly<FullscreenLinkProps>): JSX.Element {
26+
const history = useHistory()
27+
const fullscreenUrl = addFullscreenParam(to)
28+
29+
const handleClick = useCallback(
30+
(e: MouseEvent<HTMLAnchorElement>) => {
31+
// Allow normal link behavior for modifier keys or non-left clicks
32+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) {
33+
return
34+
}
35+
36+
e.preventDefault()
37+
38+
// Request fullscreen first, then navigate
39+
document.documentElement
40+
.requestFullscreen({
41+
navigationUI: 'hide',
42+
})
43+
.then(() => {
44+
history.push(fullscreenUrl)
45+
})
46+
.catch((err) => {
47+
// If fullscreen fails (e.g., not allowed), still navigate
48+
catchError('FullscreenLink.requestFullscreen')(err)
49+
history.push(fullscreenUrl)
50+
})
51+
},
52+
[fullscreenUrl, history]
53+
)
54+
55+
return (
56+
<Link to={fullscreenUrl} onClick={handleClick} className={className}>
57+
{children}
58+
</Link>
59+
)
60+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
.fullscreen-overlay {
2+
position: fixed;
3+
inset: 0;
4+
width: 100%;
5+
height: 100%;
6+
background-color: rgb(0 0 0 / 0.7);
7+
display: flex;
8+
align-items: center;
9+
justify-content: center;
10+
cursor: pointer;
11+
z-index: 10000;
12+
backdrop-filter: blur(2px);
13+
border: none;
14+
padding: 0;
15+
margin: 0;
16+
}
17+
18+
.fullscreen-overlay__content {
19+
text-align: center;
20+
color: white;
21+
user-select: none;
22+
}
23+
24+
.fullscreen-overlay__icon {
25+
font-size: 4rem;
26+
margin-bottom: 1rem;
27+
}
28+
29+
.fullscreen-overlay__text {
30+
font-size: 1.5rem;
31+
font-weight: 500;
32+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useState, useEffect, useCallback } from 'react'
2+
import { useLocation } from 'react-router-dom'
3+
import { parse as queryStringParse } from 'query-string'
4+
import { useTranslation } from 'react-i18next'
5+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
6+
import { faExpand } from '@fortawesome/free-solid-svg-icons'
7+
import { catchError } from '../../lib/lib.js'
8+
9+
import './FullscreenOverlay.scss'
10+
11+
const PARAM_NAME_FULLSCREEN = 'fullscreen'
12+
13+
/**
14+
* A semi-transparent overlay that prompts the user to click to enter fullscreen mode.
15+
* Only shows when fullscreen=1 is in the URL query string.
16+
* Automatically hides when fullscreen is active, and reappears when fullscreen is exited.
17+
*/
18+
export function FullscreenOverlay(): JSX.Element | null {
19+
const { t } = useTranslation()
20+
const location = useLocation()
21+
const [isFullscreen, setIsFullscreen] = useState(() => document.fullscreenElement !== null)
22+
23+
// Check if fullscreen=1 is in the URL
24+
const fullscreenRequested = (() => {
25+
const queryParams = queryStringParse(location.search, { arrayFormat: 'comma' })
26+
const fullscreenParam = queryParams[PARAM_NAME_FULLSCREEN] ?? false
27+
return Array.isArray(fullscreenParam) ? fullscreenParam[0] === '1' : fullscreenParam === '1'
28+
})()
29+
30+
useEffect(() => {
31+
function handleFullscreenChange() {
32+
setIsFullscreen(document.fullscreenElement !== null)
33+
}
34+
35+
document.addEventListener('fullscreenchange', handleFullscreenChange)
36+
return () => {
37+
document.removeEventListener('fullscreenchange', handleFullscreenChange)
38+
}
39+
}, [])
40+
41+
const requestFullscreen = useCallback(() => {
42+
if (document.fullscreenElement !== null) return
43+
44+
document.documentElement
45+
.requestFullscreen({
46+
navigationUI: 'hide',
47+
})
48+
.catch(catchError('FullscreenOverlay.requestFullscreen'))
49+
}, [])
50+
51+
// Don't render if fullscreen not requested, already fullscreen, or not supported
52+
if (!fullscreenRequested || isFullscreen || !document.fullscreenEnabled) {
53+
return null
54+
}
55+
56+
return (
57+
<button
58+
className="fullscreen-overlay"
59+
onClick={requestFullscreen}
60+
type="button"
61+
aria-label={t('Click or press Enter for fullscreen')}
62+
>
63+
<div className="fullscreen-overlay__content">
64+
<div className="fullscreen-overlay__icon">
65+
<FontAwesomeIcon icon={faExpand} />
66+
</div>
67+
<div className="fullscreen-overlay__text">{t('Click anywhere for fullscreen')}</div>
68+
</div>
69+
</button>
70+
)
71+
}

packages/webui/src/client/ui/ClockView/PresenterConfigForm.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub'
88
import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData.js'
99
import { RundownLayouts } from '../../collections/index.js'
1010
import { RundownLayoutsAPI } from '../../lib/rundownLayouts.js'
11+
import { FullscreenLink } from './FullscreenLink.js'
1112

1213
import './PrompterConfigForm.scss'
1314

@@ -86,9 +87,12 @@ export function PresenterConfigForm({ studioId }: Readonly<{ studioId: StudioId
8687
</Form.Label>
8788
<Form.Control type="text" size="sm" readOnly value={generatedUrl} onClick={(e) => e.currentTarget.select()} />
8889
</Form.Group>
89-
<Link to={generatedUrl} className="btn btn-primary">
90+
<Link to={generatedUrl} className="btn btn-primary me-2">
9091
{t('Open Presenter Screen')}
9192
</Link>
93+
<FullscreenLink to={generatedUrl} className="btn btn-secondary">
94+
{t('Open Fullscreen')}
95+
</FullscreenLink>
9296
</div>
9397
</div>
9498
)

0 commit comments

Comments
 (0)