Skip to content

Commit 54bed04

Browse files
committed
feat: live-view fullscreen → timeline fullscreen handoff (#316)
When a user clicks 'View in Timeline' while a stream is playing in native fullscreen on the Live View page, the Timeline page now: 1. Pre-selects 'fine' keyboard-navigation mode (arrow keys seek 1 s instead of jumping recordings) — no extra click required. 2. Shows a translucent overlay on the video player with a fullscreen icon and 'Click to enter fullscreen' label. Clicking the overlay is the user gesture required by the browser's transient-activation security policy, so requestFullscreen() succeeds immediately. An × button lets the user dismiss the prompt without entering fullscreen. Both ?fullscreen=1 and ?nav=fine are one-shot URL params: they are removed from the address bar by updateUrlParams() on the first stream/date change, so sharing or refreshing the URL does not re-trigger the behaviour. Adds timeline.clickToEnterFullscreen i18n key to all 8 locales. Closes #316
1 parent c1b3efc commit 54bed04

File tree

14 files changed

+73
-16
lines changed

14 files changed

+73
-16
lines changed

web/js/components/preact/HLSVideoCell.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,7 +783,10 @@ export function HLSVideoCell({
783783
className="timeline-btn"
784784
title={t('live.viewInTimeline')}
785785
aria-label={t('live.viewInTimeline')}
786-
onClick={(event) => forceNavigation(formatUtils.getTimelineUrl(stream.name, new Date().toISOString()), event)}
786+
onClick={(event) => {
787+
const fromFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
788+
forceNavigation(formatUtils.getTimelineUrl(stream.name, new Date().toISOString(), fromFullscreen), event);
789+
}}
787790
style={{
788791
backgroundColor: 'transparent',
789792
border: 'none',

web/js/components/preact/MSEVideoCell.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,10 @@ export function MSEVideoCell({
707707
<button
708708
type="button"
709709
className="timeline-btn"
710-
onClick={(event) => forceNavigation(formatUtils.getTimelineUrl(stream.name, new Date().toISOString()), event)}
710+
onClick={(event) => {
711+
const fromFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
712+
forceNavigation(formatUtils.getTimelineUrl(stream.name, new Date().toISOString(), fromFullscreen), event);
713+
}}
711714
style={{
712715
padding: '8px 12px',
713716
backgroundColor: 'rgba(0, 0, 0, 0.7)',

web/js/components/preact/WebRTCVideoCell.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1449,7 +1449,10 @@ export function WebRTCVideoCell({
14491449
className="timeline-btn"
14501450
title={t('live.viewInTimeline')}
14511451
aria-label={t('live.viewInTimeline')}
1452-
onClick={(event) => forceNavigation(formatUtils.getTimelineUrl(stream.name, new Date().toISOString()), event)}
1452+
onClick={(event) => {
1453+
const fromFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
1454+
forceNavigation(formatUtils.getTimelineUrl(stream.name, new Date().toISOString(), fromFullscreen), event);
1455+
}}
14531456
style={{
14541457
backgroundColor: 'transparent',
14551458
border: 'none',

web/js/components/preact/recordings/formatUtils.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,11 @@ export const formatUtils = {
102102
* as local time, so we convert to local before building the URL.
103103
* @param {string} stream - Stream name
104104
* @param {number|string} startTime - Unix epoch (s), ISO string, or legacy UTC string
105+
* @param {boolean} [fromFullscreen=false] - When true, appends fullscreen=1&nav=fine so the
106+
* Timeline page can prompt the user to re-enter fullscreen and pre-select 'fine' nav mode
105107
* @returns {string} Timeline URL with local date and time params
106108
*/
107-
getTimelineUrl: (stream, startTime) => {
109+
getTimelineUrl: (stream, startTime, fromFullscreen = false) => {
108110
const base = `timeline.html?stream=${encodeURIComponent(stream || '')}`;
109111
if (startTime === null || startTime === undefined || startTime === '') return `${base}&date=&time=`;
110112

@@ -119,7 +121,8 @@ export const formatUtils = {
119121

120122
const date = local.format('YYYY-MM-DD');
121123
const time = local.format('HH:mm:ss');
122-
return `${base}&date=${date}&time=${time}`;
124+
const url = `${base}&date=${date}&time=${time}`;
125+
return fromFullscreen ? `${url}&fullscreen=1&nav=fine` : url;
123126
},
124127

125128
/**

web/js/components/preact/timeline/TimelinePage.jsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,10 @@ function parseUrlParams() {
164164
return {
165165
stream: params.get('stream') || '',
166166
date: params.get('date') || currentDateInputValue(),
167-
time: params.get('time') || '', // Optional HH:MM:SS to auto-seek on load
168-
ids: params.get('ids') || '' // Comma-separated recording IDs for selected-recordings mode
167+
time: params.get('time') || '', // Optional HH:MM:SS to auto-seek on load
168+
ids: params.get('ids') || '', // Comma-separated recording IDs for selected-recordings mode
169+
fullscreen: params.get('fullscreen') === '1', // Prompt user to re-enter fullscreen (from Live View)
170+
nav: params.get('nav') || '', // Pre-select keyboard nav mode ('fine' | '')
169171
};
170172
}
171173

@@ -177,7 +179,9 @@ function updateUrlParams(stream, date) {
177179
const url = new URL(window.location.href);
178180
url.searchParams.set('stream', stream);
179181
url.searchParams.set('date', date);
180-
url.searchParams.delete('time'); // Remove one-time seek param after initial load
182+
url.searchParams.delete('time'); // Remove one-time seek param after initial load
183+
url.searchParams.delete('fullscreen'); // Remove one-shot fullscreen hint
184+
url.searchParams.delete('nav'); // Remove one-shot nav hint
181185
window.history.replaceState({}, '', url);
182186
}
183187

@@ -237,7 +241,9 @@ export function TimelinePage() {
237241
const [idsSegmentInfo, setIdsSegmentInfo] = useState(null); // metadata from IDs endpoint
238242
const [idsTimelineSegments, setIdsTimelineSegments] = useState([]);
239243
const [isDownloadModalOpen, setIsDownloadModalOpen] = useState(false);
240-
const [keyboardNavigationMode, setKeyboardNavigationMode] = useState('broad');
244+
const [keyboardNavigationMode, setKeyboardNavigationMode] = useState(
245+
urlParams.nav === 'fine' ? 'fine' : 'broad'
246+
);
241247

242248
// Refs
243249
const timelineContainerRef = useRef(null);
@@ -1046,7 +1052,7 @@ export function TimelinePage() {
10461052
return (
10471053
<>
10481054
{/* Video player */}
1049-
<TimelinePlayer videoElementRef={videoElementRef} />
1055+
<TimelinePlayer videoElementRef={videoElementRef} autoFullscreen={urlParams.fullscreen} />
10501056

10511057
{/* Playback controls (includes time display) */}
10521058
<TimelineControls />

web/js/components/preact/timeline/TimelinePlayer.jsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const DETECTION_SCALE_BASE = 400; // Baseline display dimension (px) for detecti
2121
* TimelinePlayer component
2222
* @returns {JSX.Element} TimelinePlayer component
2323
*/
24-
export function TimelinePlayer({ videoElementRef = null }) {
24+
export function TimelinePlayer({ videoElementRef = null, autoFullscreen = false }) {
2525
const { t } = useI18n();
2626
// Local state
2727
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(-1);
@@ -32,6 +32,11 @@ export function TimelinePlayer({ videoElementRef = null }) {
3232
const [isFullscreen, setIsFullscreen] = useState(false);
3333
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
3434
const [segmentRecordingData, setSegmentRecordingData] = useState(null);
35+
// When arriving from a Live View fullscreen session, show a one-time overlay
36+
// that lets the user re-enter fullscreen with a single click. Browser security
37+
// (transient activation requirement) prevents auto-calling requestFullscreen()
38+
// after a page navigation, so we surface this clear call-to-action instead.
39+
const [showFullscreenPrompt, setShowFullscreenPrompt] = useState(autoFullscreen);
3540

3641
// Refs
3742
const videoRef = useRef(null);
@@ -866,6 +871,32 @@ export function TimelinePlayer({ videoElementRef = null }) {
866871
/>
867872
)}
868873

874+
{/* Fullscreen prompt — shown when arriving from a Live View fullscreen session.
875+
Clicking the overlay is the user gesture needed for requestFullscreen(). */}
876+
{showFullscreenPrompt && (
877+
<div
878+
className="absolute inset-0 flex items-center justify-center bg-black/65 cursor-pointer rounded-lg"
879+
style={{ zIndex: 10 }}
880+
onClick={async () => {
881+
setShowFullscreenPrompt(false);
882+
await handleToggleFullscreen();
883+
}}
884+
>
885+
<div className="flex flex-col items-center gap-3 text-white pointer-events-none select-none">
886+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
887+
fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
888+
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
889+
</svg>
890+
<span className="text-sm font-medium">{t('timeline.clickToEnterFullscreen')}</span>
891+
</div>
892+
<button
893+
className="absolute top-2 right-2 w-7 h-7 flex items-center justify-center rounded-full bg-black/40 text-white/70 hover:text-white text-lg leading-none"
894+
onClick={(e) => { e.stopPropagation(); setShowFullscreenPrompt(false); }}
895+
aria-label={t('timeline.exitFullscreen')}
896+
>×</button>
897+
</div>
898+
)}
899+
869900
{/* Add a message for invalid segments */}
870901
<div
871902
className={`absolute inset-0 flex items-center justify-center bg-black bg-opacity-70 text-white text-center p-4 ${currentSegmentIndex >= 0 && segments.length > 0 ? 'hidden' : ''}`}

web/public/locales/de.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,5 +287,6 @@
287287
"fields.email": "E-Mail",
288288
"fields.role": "Rolle",
289289
"footer.github": "GitHub",
290-
"live.cannotConnectToSource": "Keine Verbindung zur Kameraquelle möglich"
290+
"live.cannotConnectToSource": "Keine Verbindung zur Kameraquelle möglich",
291+
"timeline.clickToEnterFullscreen": "Klicken, um Vollbild zu aktivieren"
291292
}

web/public/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@
335335
"timeline.selectSegmentOrPlay": "Click on a segment in the timeline or use the play button to start playback.",
336336
"timeline.exitFullscreen": "Exit Fullscreen",
337337
"timeline.enterFullscreen": "Enter Fullscreen",
338+
"timeline.clickToEnterFullscreen": "Click to enter fullscreen",
338339
"timeline.fullscreen": "Fullscreen",
339340
"timeline.takeSnapshot": "Take Snapshot",
340341
"timeline.snapshot": "Snapshot",

web/public/locales/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@
259259
"timeline.selectSegmentOrPlay": "Haz clic en un segmento de la línea de tiempo o usa el botón de reproducción para iniciar la reproducción.",
260260
"timeline.exitFullscreen": "Salir de pantalla completa",
261261
"timeline.enterFullscreen": "Entrar en pantalla completa",
262+
"timeline.clickToEnterFullscreen": "Haz clic para entrar en pantalla completa",
262263
"timeline.fullscreen": "Pantalla completa",
263264
"timeline.takeSnapshot": "Tomar instantánea",
264265
"timeline.snapshot": "Instantánea",

web/public/locales/fr.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,5 +287,6 @@
287287
"fields.email": "E-mail",
288288
"fields.role": "Rôle",
289289
"footer.github": "GitHub",
290-
"live.cannotConnectToSource": "Impossible de se connecter à la source caméra"
290+
"live.cannotConnectToSource": "Impossible de se connecter à la source caméra",
291+
"timeline.clickToEnterFullscreen": "Cliquez pour activer le plein écran"
291292
}

0 commit comments

Comments
 (0)