Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions web/js/components/preact/LiveSlideshowControl.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useI18n } from '../../i18n.js';
import { LIVE_SLIDESHOW_INTERVALS } from './useLiveSlideshow.js';

/**
* Slideshow toggle for the live view toolbar, with an interval picker shown
* while it is on. In a 1x1 layout it cycles cameras; otherwise grid pages.
*/
export function LiveSlideshowControl({ enabled, interval, isSingleStream, onToggle, onIntervalChange }) {
const { t } = useI18n();
const label = enabled
? t('live.slideshowStop')
: t(isSingleStream ? 'live.slideshowStartCameras' : 'live.slideshowStartPages');
return (
<div className="inline-flex items-center gap-1" data-testid="live-slideshow">
<button
type="button"
className={`rounded-full p-2 focus:outline-none focus:ring-2 focus:ring-primary ${enabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}
onClick={() => onToggle(!enabled)}
title={label}
aria-label={label}
aria-pressed={enabled}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="2" y="4" width="20" height="14" rx="2" />
{enabled
? <path d="M10 8v6M14 8v6" />
: <path d="M10 8l5 3-5 3z" />}
<path d="M8 21h8" />
</svg>
</button>
{enabled && (
<select
className="px-2 py-2 border border-border rounded-md bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
value={interval}
onChange={(event) => onIntervalChange(event.currentTarget.value)}
title={t('live.slideshowInterval')}
aria-label={t('live.slideshowInterval')}
>
{LIVE_SLIDESHOW_INTERVALS.map((seconds) => (
<option key={seconds} value={seconds}>{t('live.slideshowSeconds', { seconds })}</option>
))}
</select>
)}
</div>
);
}

export default LiveSlideshowControl;
26 changes: 26 additions & 0 deletions web/js/components/preact/LiveView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { AlwaysFullscreenToggle } from './AlwaysFullscreenToggle.jsx';
import { useAlwaysFullscreenOnTap } from './useAlwaysFullscreenOnTap.js';
import { LiveDisplayModeToggle } from './LiveDisplayModeToggle.jsx';
import { useLiveDisplayMode } from './useLiveDisplayMode.js';
import { LiveSlideshowControl } from './LiveSlideshowControl.jsx';
import { useLiveSlideshow } from './useLiveSlideshow.js';
import { usePullToRefresh } from './usePullToRefresh.js';
import { shouldShowGestureTip } from './mobileLiveGestures.js';
import { fetchAllStreamSummaries } from '../../utils/stream-summaries.js';
Expand Down Expand Up @@ -522,6 +524,20 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
});
}, [applyOperatorLayout, setOperatorSurface]);

// Auto-advance cameras (1x1) or grid pages on a timer. Paused while the
// operator is rearranging tiles, has one tile in native fullscreen, or is
// looking at the building plan instead of the grid.
const slideshow = useLiveSlideshow({
paused: reorderMode || !!fullscreenCellStream || operatorSurface !== 'grid',
isSingleStream,
orderedStreams,
selectedStream,
setSelectedStream,
currentPage,
setCurrentPage,
maxStreams,
});

// Ensure current page is valid when orderedStreams or layout changes
useEffect(() => {
if (orderedStreams.length === 0) return;
Expand Down Expand Up @@ -824,6 +840,16 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
/>
)}

{slideshow.canCycle && (
<LiveSlideshowControl
enabled={slideshow.enabled}
interval={slideshow.interval}
isSingleStream={isSingleStream}
onToggle={slideshow.setEnabled}
onIntervalChange={slideshow.setInterval}
/>
)}

{orderedStreams.length > 1 && (
<button
className={`p-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary ${reorderMode ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-secondary hover:bg-secondary/80 text-secondary-foreground'}`}
Expand Down
26 changes: 26 additions & 0 deletions web/js/components/preact/WebRTCView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { AlwaysFullscreenToggle } from './AlwaysFullscreenToggle.jsx';
import { useAlwaysFullscreenOnTap } from './useAlwaysFullscreenOnTap.js';
import { LiveDisplayModeToggle } from './LiveDisplayModeToggle.jsx';
import { useLiveDisplayMode } from './useLiveDisplayMode.js';
import { LiveSlideshowControl } from './LiveSlideshowControl.jsx';
import { useLiveSlideshow } from './useLiveSlideshow.js';
import { usePullToRefresh } from './usePullToRefresh.js';
import { shouldShowGestureTip } from './mobileLiveGestures.js';
import { fetchAllStreamSummaries } from '../../utils/stream-summaries.js';
Expand Down Expand Up @@ -553,6 +555,20 @@ export function WebRTCView({ audioDisabled = false, isAutoDisabled = false, isWe
});
}, [applyOperatorLayout, setOperatorSurface]);

// Auto-advance cameras (1x1) or grid pages on a timer. Paused while the
// operator is rearranging tiles, has one tile in native fullscreen, or is
// looking at the building plan instead of the grid.
const slideshow = useLiveSlideshow({
paused: reorderMode || !!fullscreenCellStream || operatorSurface !== 'grid',
isSingleStream,
orderedStreams,
selectedStream,
setSelectedStream,
currentPage,
setCurrentPage,
maxStreams,
});

// Ensure current page is valid when orderedStreams or maxStreams changes
useEffect(() => {
if (orderedStreams.length === 0) return;
Expand Down Expand Up @@ -854,6 +870,16 @@ export function WebRTCView({ audioDisabled = false, isAutoDisabled = false, isWe
/>
)}

{slideshow.canCycle && (
<LiveSlideshowControl
enabled={slideshow.enabled}
interval={slideshow.interval}
isSingleStream={isSingleStream}
onToggle={slideshow.setEnabled}
onIntervalChange={slideshow.setInterval}
/>
)}

{orderedStreams.length > 1 && (
<button
className={`p-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary ${reorderMode ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-secondary hover:bg-secondary/80 text-secondary-foreground'}`}
Expand Down
156 changes: 156 additions & 0 deletions web/js/components/preact/useLiveSlideshow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';

/**
* Live view slideshow: auto-advance the live grid on a timer.
*
* 1x1 layout - cycles through the cameras one at a time.
* larger grid - cycles through the pages of the grid.
*
* The preference is persisted in localStorage and mirrored to the URL as
* ?slideshow=<seconds>, so a wall display or kiosk can bookmark it.
*/
export const LIVE_SLIDESHOW_KEY = 'lightnvr-live-slideshow';
export const LIVE_SLIDESHOW_INTERVAL_KEY = 'lightnvr-live-slideshow-interval';
export const LIVE_SLIDESHOW_URL_PARAM = 'slideshow';
export const LIVE_SLIDESHOW_INTERVALS = Object.freeze([5, 10, 15, 30, 60]);
export const DEFAULT_LIVE_SLIDESHOW_INTERVAL = 10;

export function normalizeSlideshowInterval(value) {
const seconds = Number.parseInt(value, 10);
return LIVE_SLIDESHOW_INTERVALS.includes(seconds) ? seconds : DEFAULT_LIVE_SLIDESHOW_INTERVAL;
}

/**
* Initial slideshow state. A ?slideshow=<seconds> URL param wins over the
* stored preference; ?slideshow=0 turns it off.
*
* @returns {{enabled: boolean, interval: number}}
*/
export function readLiveSlideshow(storage, search) {
try {
const param = new URLSearchParams(search ?? globalThis.location?.search ?? '').get(LIVE_SLIDESHOW_URL_PARAM);
if (param !== null) {
if (param === '0' || param === 'off') {
return { enabled: false, interval: DEFAULT_LIVE_SLIDESHOW_INTERVAL };
}
return { enabled: true, interval: normalizeSlideshowInterval(param) };
}
} catch {
// Fall through to the stored preference.
}
try {
const selectedStorage = storage ?? globalThis.localStorage;
return {
enabled: selectedStorage?.getItem(LIVE_SLIDESHOW_KEY) === 'true',
interval: normalizeSlideshowInterval(selectedStorage?.getItem(LIVE_SLIDESHOW_INTERVAL_KEY)),
};
} catch {
return { enabled: false, interval: DEFAULT_LIVE_SLIDESHOW_INTERVAL };
}
}

export function writeLiveSlideshow({ enabled, interval }, storage) {
try {
const selectedStorage = storage ?? globalThis.localStorage;
selectedStorage?.setItem(LIVE_SLIDESHOW_KEY, String(!!enabled));
selectedStorage?.setItem(LIVE_SLIDESHOW_INTERVAL_KEY, String(normalizeSlideshowInterval(interval)));
} catch {
// Storage can be disabled in private browsing; keep the in-memory choice.
}
}

/** Number of slides the slideshow would cycle through. */
export function slideshowSlideCount({ isSingleStream, streamCount, maxStreams }) {
if (streamCount <= 0) return 0;
if (isSingleStream) return streamCount;
return Math.ceil(streamCount / Math.max(1, maxStreams));
}

/**
* The next slide after the current one, wrapping around at the end.
* Returns {selectedStream} in 1x1 mode, {page} in grid mode, or null when
* there is nothing to cycle to.
*/
export function nextSlideshowTarget({ isSingleStream, streamNames, selectedStream, currentPage, maxStreams }) {
const slides = slideshowSlideCount({ isSingleStream, streamCount: streamNames.length, maxStreams });
if (slides < 2) return null;
if (isSingleStream) {
const index = streamNames.indexOf(selectedStream);
return { selectedStream: streamNames[(index + 1) % streamNames.length] };
}
return { page: (Math.max(0, currentPage) + 1) % slides };
}

function useDocumentVisible() {
const [visible, setVisible] = useState(() => typeof document === 'undefined' || !document.hidden);
useEffect(() => {
const onChange = () => setVisible(!document.hidden);
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}, []);
return visible;
}

/**
* Drive the live view slideshow. The countdown restarts whenever the visible
* slide changes, so manual paging or camera selection gets a full interval.
* It also stops while `paused` is set (reorder mode, a tile in native
* fullscreen) and while the browser tab is hidden.
*/
export function useLiveSlideshow({
paused = false,
isSingleStream,
orderedStreams,
selectedStream,
setSelectedStream,
currentPage,
setCurrentPage,
maxStreams,
}) {
const [state, setState] = useState(() => readLiveSlideshow());
const visible = useDocumentVisible();

useEffect(() => {
writeLiveSlideshow(state);
const url = new URL(window.location);
if (state.enabled) url.searchParams.set(LIVE_SLIDESHOW_URL_PARAM, String(state.interval));
else url.searchParams.delete(LIVE_SLIDESHOW_URL_PARAM);
window.history.replaceState({}, '', url);
}, [state]);

// Streams are refetched periodically; key the timer on the names so a
// refresh with the same cameras does not restart the countdown.
const streamNames = useMemo(() => orderedStreams.map((stream) => stream.name), [orderedStreams]);
const namesKey = streamNames.join('\n');
const latest = useRef(null);
latest.current = { isSingleStream, streamNames, selectedStream, currentPage, maxStreams, setSelectedStream, setCurrentPage };

const slideCount = slideshowSlideCount({ isSingleStream, streamCount: streamNames.length, maxStreams });
const canCycle = slideCount > 1;
const running = state.enabled && canCycle && !paused && visible;

useEffect(() => {
if (!running) return undefined;
const timer = setTimeout(() => {
const current = latest.current;
const target = nextSlideshowTarget(current);
if (!target) return;
if (target.selectedStream !== undefined) current.setSelectedStream(target.selectedStream);
else current.setCurrentPage(target.page);
}, state.interval * 1000);
return () => clearTimeout(timer);
}, [running, state.interval, isSingleStream, selectedStream, currentPage, maxStreams, namesKey]);

const setEnabled = useCallback((enabled) => setState((s) => ({ ...s, enabled: !!enabled })), []);
const setInterval = useCallback((interval) =>
setState((s) => ({ ...s, interval: normalizeSlideshowInterval(interval) })), []);

return {
enabled: state.enabled,
interval: state.interval,
canCycle,
running,
setEnabled,
setInterval,
};
}
5 changes: 5 additions & 0 deletions web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1555,6 +1555,11 @@
"live.displayModeFill": "Fill",
"live.displayModeFitDescription": "Fit: show the whole frame with letterboxing",
"live.displayModeFillDescription": "Fill: fill the tile and crop the frame edges",
"live.slideshowStartCameras": "Start slideshow: cycle through cameras",
"live.slideshowStartPages": "Start slideshow: cycle through grid pages",
"live.slideshowStop": "Stop slideshow",
"live.slideshowInterval": "Slideshow interval",
"live.slideshowSeconds": "{seconds}s",
"streams.toggleDisable": "Disable stream",
"streams.toggleEnable": "Enable stream",
"streams.readOnlyInsufficientPermissions": "Read-only (insufficient permissions to modify)",
Expand Down
5 changes: 5 additions & 0 deletions web/public/locales/fil.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,11 @@
"live.displayModeFill": "Punuin",
"live.displayModeFitDescription": "Iangkop: ipakita ang buong frame na may mga bar sa gilid",
"live.displayModeFillDescription": "Punuin: punuin ang tile at putulin ang mga gilid ng frame",
"live.slideshowStartCameras": "Simulan ang slideshow: isa-isahin ang mga camera",
"live.slideshowStartPages": "Simulan ang slideshow: isa-isahin ang mga pahina ng grid",
"live.slideshowStop": "Itigil ang slideshow",
"live.slideshowInterval": "Pagitan ng slideshow",
"live.slideshowSeconds": "{seconds}s",
"recordings.filters": "Mga Filter",
"recordings.showFilters": "Ipakita ang mga filter",
"recordings.hideFilters": "Itago ang mga filter",
Expand Down
Loading
Loading