Skip to content

Commit 41f121d

Browse files
committed
feat: privacy quick-disable toggle for streams (#151)
Add a fast per-stream enable/disable toggle accessible from two places: 1. Streams table (StreamsView): - Power icon button in the row-actions column (between Clone and Delete) - Enabled streams: clicking shows window.confirm() before disabling - Disabled streams: clicking re-enables immediately (no confirm needed) - Button colour: green (enabled) / muted (disabled) - Uses existing DELETE (soft-disable) and PUT enable_disabled:true endpoints 2. Live View cells (MSEVideoCell & HLSVideoCell): - Power icon button added to the per-cell controls overlay - Clicking opens an inline overlay with a confirmation message - On confirm: calls DELETE to soft-disable the stream - Shows a dark 'Stream Disabled' overlay with a green 'Enable Stream' button - Re-enabling calls PUT enable_disabled:true; overlay clears on success - Both actions invalidate the ['streams'] query so the Streams page reflects the new state on next render New i18n keys (en.json): live.disableStream, live.disableStreamConfirm, live.streamDisabled, live.enableStream, streams.toggleDisable, streams.toggleEnable
1 parent a8bdb88 commit 41f121d

4 files changed

Lines changed: 322 additions & 0 deletions

File tree

web/js/components/preact/HLSVideoCell.jsx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { formatFilenameTimestamp } from '../../utils/date-utils.js';
1515
import { forceNavigation } from '../../utils/navigation-utils.js';
1616
import { formatUtils } from './recordings/formatUtils.js';
1717
import { useI18n } from '../../i18n.js';
18+
import { useQueryClient } from '../../query-client.js';
1819
import Hls from 'hls.js';
1920

2021
/**
@@ -36,13 +37,20 @@ export function HLSVideoCell({
3637
globalShowDetections = true
3738
}) {
3839
const { t } = useI18n();
40+
const queryClient = useQueryClient();
41+
3942
// Component state
4043
const [isLoading, setIsLoading] = useState(true);
4144
const [error, setError] = useState(null);
4245
const [isPlaying, setIsPlaying] = useState(false);
4346
const [retryCount, setRetryCount] = useState(0);
4447
const [showRefreshConfirm, setShowRefreshConfirm] = useState(false);
4548

49+
// Disable/enable stream state
50+
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
51+
const [localIsDisabled, setLocalIsDisabled] = useState(false);
52+
const [isTogglingEnabled, setIsTogglingEnabled] = useState(false);
53+
4654
// HLS source state: 'go2rtc' (go2rtc's dynamic HLS), 'native' (lightNVR FFmpeg-based HLS), or 'failed'
4755
// Default to native lightNVR HLS (reliable, always running when streaming enabled)
4856
// go2rtc mode is used only when the backend reports go2rtc is available for this stream
@@ -538,6 +546,46 @@ export function HLSVideoCell({
538546
setRetryCount(prev => prev + 1);
539547
};
540548

549+
/**
550+
* Handle disable stream (soft delete)
551+
*/
552+
const handleDisableStream = async () => {
553+
setIsTogglingEnabled(true);
554+
try {
555+
const res = await fetch(`/api/streams/${encodeURIComponent(stream.name)}`, { method: 'DELETE' });
556+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
557+
setLocalIsDisabled(true);
558+
setShowDisableConfirm(false);
559+
queryClient.invalidateQueries({ queryKey: ['streams'] });
560+
} catch (err) {
561+
showStatusMessage(`${t('live.disableStream')}: ${err.message}`, 'error', 5000);
562+
setShowDisableConfirm(false);
563+
} finally {
564+
setIsTogglingEnabled(false);
565+
}
566+
};
567+
568+
/**
569+
* Handle enable stream
570+
*/
571+
const handleEnableStream = async () => {
572+
setIsTogglingEnabled(true);
573+
try {
574+
const res = await fetch(`/api/streams/${encodeURIComponent(stream.name)}`, {
575+
method: 'PUT',
576+
headers: { 'Content-Type': 'application/json' },
577+
body: JSON.stringify({ enable_disabled: true, enabled: true })
578+
});
579+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
580+
setLocalIsDisabled(false);
581+
queryClient.invalidateQueries({ queryKey: ['streams'] });
582+
} catch (err) {
583+
showStatusMessage(`${t('live.enableStream')}: ${err.message}`, 'error', 5000);
584+
} finally {
585+
setIsTogglingEnabled(false);
586+
}
587+
};
588+
541589
return (
542590
<div
543591
className="video-cell"
@@ -716,6 +764,31 @@ export function HLSVideoCell({
716764
}}
717765
/>
718766
</div>
767+
{/* Disable stream button */}
768+
<button
769+
type="button"
770+
title={t('live.disableStream')}
771+
onClick={() => setShowDisableConfirm(true)}
772+
style={{
773+
backgroundColor: 'transparent',
774+
border: 'none',
775+
padding: '5px',
776+
borderRadius: '4px',
777+
color: 'white',
778+
cursor: 'pointer',
779+
transition: 'background-color 0.2s ease',
780+
display: 'flex',
781+
alignItems: 'center',
782+
justifyContent: 'center'
783+
}}
784+
onMouseOver={(e) => e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.2)'}
785+
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
786+
>
787+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
788+
<path d="M18.36 6.64A9 9 0 1 1 5.64 17.36"/>
789+
<line x1="12" y1="2" x2="12" y2="12"/>
790+
</svg>
791+
</button>
719792
{/* Detection overlay toggle button */}
720793
{stream.detection_based_recording && stream.detection_model && isPlaying && (
721794
<button
@@ -993,6 +1066,68 @@ export function HLSVideoCell({
9931066
message={t('live.forceRefreshWarning')}
9941067
confirmLabel={t('common.refresh')}
9951068
/>
1069+
1070+
{/* Inline disable confirmation overlay */}
1071+
{showDisableConfirm && (
1072+
<div style={{
1073+
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
1074+
backgroundColor: 'rgba(0,0,0,0.75)', zIndex: 20,
1075+
display: 'flex', flexDirection: 'column',
1076+
alignItems: 'center', justifyContent: 'center', gap: '12px',
1077+
padding: '16px', textAlign: 'center'
1078+
}}>
1079+
<p style={{ color: 'white', fontSize: '14px', maxWidth: '240px', lineHeight: '1.4' }}>
1080+
{t('live.disableStreamConfirm')}
1081+
</p>
1082+
<div style={{ display: 'flex', gap: '8px' }}>
1083+
<button
1084+
onClick={handleDisableStream}
1085+
disabled={isTogglingEnabled}
1086+
style={{
1087+
padding: '6px 16px', backgroundColor: '#dc2626', color: 'white',
1088+
border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', fontSize: '13px'
1089+
}}
1090+
>
1091+
{t('live.disableStream')}
1092+
</button>
1093+
<button
1094+
onClick={() => setShowDisableConfirm(false)}
1095+
style={{
1096+
padding: '6px 16px', backgroundColor: 'rgba(255,255,255,0.2)', color: 'white',
1097+
border: '1px solid rgba(255,255,255,0.4)', borderRadius: '4px', cursor: 'pointer', fontSize: '13px'
1098+
}}
1099+
>
1100+
{t('common.cancel')}
1101+
</button>
1102+
</div>
1103+
</div>
1104+
)}
1105+
1106+
{/* Locally disabled overlay */}
1107+
{localIsDisabled && (
1108+
<div style={{
1109+
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
1110+
backgroundColor: 'rgba(0,0,0,0.85)', zIndex: 15,
1111+
display: 'flex', flexDirection: 'column',
1112+
alignItems: 'center', justifyContent: 'center', gap: '12px'
1113+
}}>
1114+
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
1115+
<path d="M18.36 6.64A9 9 0 1 1 5.64 17.36"/>
1116+
<line x1="12" y1="2" x2="12" y2="12"/>
1117+
</svg>
1118+
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>{t('live.streamDisabled')}</p>
1119+
<button
1120+
onClick={handleEnableStream}
1121+
disabled={isTogglingEnabled}
1122+
style={{
1123+
padding: '6px 16px', backgroundColor: '#16a34a', color: 'white',
1124+
border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', fontSize: '13px'
1125+
}}
1126+
>
1127+
{t('live.enableStream')}
1128+
</button>
1129+
</div>
1130+
)}
9961131
</div>
9971132
);
9981133
}

web/js/components/preact/MSEVideoCell.jsx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { formatFilenameTimestamp } from '../../utils/date-utils.js';
1515
import { forceNavigation } from '../../utils/navigation-utils.js';
1616
import { formatUtils } from './recordings/formatUtils.js';
1717
import { useI18n } from '../../i18n.js';
18+
import { useQueryClient } from '../../query-client.js';
1819

1920
/**
2021
* MSEVideoCell component
@@ -35,6 +36,8 @@ export function MSEVideoCell({
3536
globalShowDetections = true
3637
}) {
3738
const { t } = useI18n();
39+
const queryClient = useQueryClient();
40+
3841
// Component state
3942
const [isLoading, setIsLoading] = useState(true);
4043
const [error, setError] = useState(null);
@@ -52,6 +55,11 @@ export function MSEVideoCell({
5255
// PTZ controls state
5356
const [showPTZControls, setShowPTZControls] = useState(false);
5457

58+
// Disable/enable stream state
59+
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
60+
const [localIsDisabled, setLocalIsDisabled] = useState(false);
61+
const [isTogglingEnabled, setIsTogglingEnabled] = useState(false);
62+
5563
// Detection overlay visibility state (per-camera toggle, constrained by global toggle)
5664
const [localShowDetections, setLocalShowDetections] = useState(true);
5765
const showDetections = globalShowDetections && localShowDetections;
@@ -371,6 +379,46 @@ export function MSEVideoCell({
371379
setIsLoading(true);
372380
};
373381

382+
/**
383+
* Handle disable stream (soft delete)
384+
*/
385+
const handleDisableStream = async () => {
386+
setIsTogglingEnabled(true);
387+
try {
388+
const res = await fetch(`/api/streams/${encodeURIComponent(stream.name)}`, { method: 'DELETE' });
389+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
390+
setLocalIsDisabled(true);
391+
setShowDisableConfirm(false);
392+
queryClient.invalidateQueries({ queryKey: ['streams'] });
393+
} catch (err) {
394+
showStatusMessage(`${t('live.disableStream')}: ${err.message}`, 'error', 5000);
395+
setShowDisableConfirm(false);
396+
} finally {
397+
setIsTogglingEnabled(false);
398+
}
399+
};
400+
401+
/**
402+
* Handle enable stream
403+
*/
404+
const handleEnableStream = async () => {
405+
setIsTogglingEnabled(true);
406+
try {
407+
const res = await fetch(`/api/streams/${encodeURIComponent(stream.name)}`, {
408+
method: 'PUT',
409+
headers: { 'Content-Type': 'application/json' },
410+
body: JSON.stringify({ enable_disabled: true, enabled: true })
411+
});
412+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
413+
setLocalIsDisabled(false);
414+
queryClient.invalidateQueries({ queryKey: ['streams'] });
415+
} catch (err) {
416+
showStatusMessage(`${t('live.enableStream')}: ${err.message}`, 'error', 5000);
417+
} finally {
418+
setIsTogglingEnabled(false);
419+
}
420+
};
421+
374422
/**
375423
* Handle snapshot button click
376424
*/
@@ -667,6 +715,31 @@ export function MSEVideoCell({
667715
{/* Snapshot button */}
668716
<SnapshotButton streamId={streamId} streamName={stream.name} onSnapshot={handleSnapshot} />
669717

718+
{/* Disable stream button */}
719+
<button
720+
type="button"
721+
title={t('live.disableStream')}
722+
onClick={() => setShowDisableConfirm(true)}
723+
style={{
724+
padding: '8px 12px',
725+
backgroundColor: 'rgba(0, 0, 0, 0.7)',
726+
color: 'white',
727+
border: 'none',
728+
borderRadius: '4px',
729+
cursor: 'pointer',
730+
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.3)',
731+
transition: 'background-color 0.2s ease',
732+
display: 'flex',
733+
alignItems: 'center',
734+
justifyContent: 'center'
735+
}}
736+
>
737+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
738+
<path d="M18.36 6.64A9 9 0 1 1 5.64 17.36"/>
739+
<line x1="12" y1="2" x2="12" y2="12"/>
740+
</svg>
741+
</button>
742+
670743
{/* Detection overlay toggle button */}
671744
{stream.detection_based_recording && stream.detection_model && isPlaying && (
672745
<button
@@ -783,6 +856,68 @@ export function MSEVideoCell({
783856
/>
784857
)}
785858

859+
{/* Inline disable confirmation overlay */}
860+
{showDisableConfirm && (
861+
<div style={{
862+
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
863+
backgroundColor: 'rgba(0,0,0,0.75)', zIndex: 20,
864+
display: 'flex', flexDirection: 'column',
865+
alignItems: 'center', justifyContent: 'center', gap: '12px',
866+
padding: '16px', textAlign: 'center'
867+
}}>
868+
<p style={{ color: 'white', fontSize: '14px', maxWidth: '240px', lineHeight: '1.4' }}>
869+
{t('live.disableStreamConfirm')}
870+
</p>
871+
<div style={{ display: 'flex', gap: '8px' }}>
872+
<button
873+
onClick={handleDisableStream}
874+
disabled={isTogglingEnabled}
875+
style={{
876+
padding: '6px 16px', backgroundColor: '#dc2626', color: 'white',
877+
border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', fontSize: '13px'
878+
}}
879+
>
880+
{t('live.disableStream')}
881+
</button>
882+
<button
883+
onClick={() => setShowDisableConfirm(false)}
884+
style={{
885+
padding: '6px 16px', backgroundColor: 'rgba(255,255,255,0.2)', color: 'white',
886+
border: '1px solid rgba(255,255,255,0.4)', borderRadius: '4px', cursor: 'pointer', fontSize: '13px'
887+
}}
888+
>
889+
{t('common.cancel')}
890+
</button>
891+
</div>
892+
</div>
893+
)}
894+
895+
{/* Locally disabled overlay */}
896+
{localIsDisabled && (
897+
<div style={{
898+
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
899+
backgroundColor: 'rgba(0,0,0,0.85)', zIndex: 15,
900+
display: 'flex', flexDirection: 'column',
901+
alignItems: 'center', justifyContent: 'center', gap: '12px'
902+
}}>
903+
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
904+
<path d="M18.36 6.64A9 9 0 1 1 5.64 17.36"/>
905+
<line x1="12" y1="2" x2="12" y2="12"/>
906+
</svg>
907+
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>{t('live.streamDisabled')}</p>
908+
<button
909+
onClick={handleEnableStream}
910+
disabled={isTogglingEnabled}
911+
style={{
912+
padding: '6px 16px', backgroundColor: '#16a34a', color: 'white',
913+
border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', fontSize: '13px'
914+
}}
915+
>
916+
{t('live.enableStream')}
917+
</button>
918+
</div>
919+
)}
920+
786921
{/* MSE mode indicator */}
787922
{showLabels && isPlaying && (
788923
<div

0 commit comments

Comments
 (0)