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
133 changes: 121 additions & 12 deletions web/js/components/preact/PTZControls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import { useState, useEffect, useCallback } from 'preact/hooks';
import { useI18n } from '../../i18n.js';
import { showSuccessToast, showErrorToast } from './ToastContainer.jsx';
import { getPtzCapabilities, getPtzPresets, savePtzPreset, setPtzHome } from './ptzActions.js';

/**
* PTZ API functions
Expand Down Expand Up @@ -34,23 +36,13 @@ const ptzApi = {
return response.json();
},

async getPresets(streamName) {
const response = await fetch(`/api/streams/${encodeURIComponent(streamName)}/ptz/presets`);
return response.json();
},

async gotoPreset(streamName, token) {
const response = await fetch(`/api/streams/${encodeURIComponent(streamName)}/ptz/goto-preset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
return response.json();
},

async getCapabilities(streamName) {
const response = await fetch(`/api/streams/${encodeURIComponent(streamName)}/ptz/capabilities`);
return response.json();
}
};

Expand Down Expand Up @@ -106,16 +98,75 @@ export function PTZControls({ stream, isVisible = true, onClose }) {
const [presets, setPresets] = useState([]);
const [isMoving, setIsMoving] = useState(false);
const [error, setError] = useState(null);
const [capabilities, setCapabilities] = useState(null);
const [canManage, setCanManage] = useState(true);
const [presetName, setPresetName] = useState('');
const [showPresetForm, setShowPresetForm] = useState(false);
const [confirmSetHome, setConfirmSetHome] = useState(false);
const [saving, setSaving] = useState(false);

// Load capabilities and presets
useEffect(() => {
if (!stream?.name || !stream?.ptz_enabled) return;

ptzApi.getPresets(stream.name)
.then(data => setPresets(data.presets || []))
let active = true;
setCapabilities(null);
setCanManage(true);
setPresets([]);
setPresetName('');
setShowPresetForm(false);
setConfirmSetHome(false);
getPtzCapabilities(stream.name)
.then(data => { if (active) setCapabilities(data); })
.catch(err => console.error('Failed to get PTZ capabilities:', err));
getPtzPresets(stream.name)
.then(data => { if (active) setPresets(data.presets || []); })
.catch(err => console.error('Failed to get PTZ presets:', err));
return () => { active = false; };
}, [stream?.name, stream?.ptz_enabled]);

const handleActionFailure = useCallback((err, message) => {
if (err.status === 403) {
setCanManage(false);
setShowPresetForm(false);
setConfirmSetHome(false);
}
showErrorToast(message);
console.error(message, err);
}, []);

const handleSavePreset = useCallback(async (event) => {
event.preventDefault();
const name = presetName.trim();
if (!stream?.name || !name || saving) return;
setSaving(true);
try {
await savePtzPreset(stream.name, name, setPresets);
setShowPresetForm(false);
setPresetName('');
showSuccessToast(t('live.ptzPresetSaved'));
} catch (err) {
handleActionFailure(err, t(err.stage === 'refresh'
? 'live.ptzPresetRefreshFailed' : 'live.ptzPresetSaveFailed'));
} finally {
setSaving(false);
}
}, [stream?.name, presetName, saving, t, handleActionFailure]);

const handleSetHome = useCallback(async () => {
if (!stream?.name || saving) return;
setSaving(true);
try {
await setPtzHome(stream.name);
setConfirmSetHome(false);
showSuccessToast(t('live.ptzHomeSet'));
} catch (err) {
handleActionFailure(err, t('live.ptzSetHomeFailed'));
} finally {
setSaving(false);
}
}, [stream?.name, saving, t, handleActionFailure]);

// Handle continuous move
const handleMoveStart = useCallback((pan, tilt, zoom) => {
if (!stream?.name) return;
Expand Down Expand Up @@ -184,7 +235,9 @@ export function PTZControls({ stream, isVisible = true, onClose }) {
<span style={{ color: 'white', fontSize: '12px', fontWeight: 'bold' }}>{t('live.ptzControl')}</span>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
style={{
background: 'none',
border: 'none',
Expand Down Expand Up @@ -216,7 +269,9 @@ export function PTZControls({ stream, isVisible = true, onClose }) {
onMouseLeave={handleMoveStop}
/>
<button
type="button"
onClick={handleHome}
aria-label={t('live.goToHomePosition')}
style={{
width: '40px',
height: '40px',
Expand Down Expand Up @@ -269,6 +324,7 @@ export function PTZControls({ stream, isVisible = true, onClose }) {
<span style={{ color: 'white', fontSize: '11px' }}>{t('live.speedLabel')}</span>
<input
type="range"
aria-label={t('live.speedLabel')}
min="0.1"
max="1"
step="0.1"
Expand All @@ -283,6 +339,7 @@ export function PTZControls({ stream, isVisible = true, onClose }) {
{presets.length > 0 && (
<div style={{ marginTop: '4px' }}>
<select
aria-label={t('live.goToPreset')}
onChange={(e) => e.target.value && handlePreset(e.target.value)}
style={{
width: '100%',
Expand All @@ -305,6 +362,58 @@ export function PTZControls({ stream, isVisible = true, onClose }) {
</div>
)}

{canManage && capabilities?.has_presets && (
showPresetForm ? (
<form onSubmit={handleSavePreset} style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<label htmlFor="ptz-preset-name" style={{ color: 'white', fontSize: '11px' }}>
{t('live.ptzPresetName')}
<input
id="ptz-preset-name"
type="text"
aria-label={t('live.ptzPresetName')}
value={presetName}
onInput={(event) => setPresetName(event.currentTarget.value)}
maxLength={64}
required
style={{ padding: '6px', borderRadius: '4px' }}
/>
</label>
<div style={{ display: 'flex', gap: '4px' }}>
<button type="submit" disabled={saving || !presetName.trim()} aria-label={t('live.ptzSavePreset')}>
{t('live.ptzSavePreset')}
</button>
<button type="button" disabled={saving} onClick={() => setShowPresetForm(false)} aria-label={t('common.cancel')}>
{t('common.cancel')}
</button>
</div>
</form>
) : (
<button type="button" onClick={() => { setConfirmSetHome(false); setShowPresetForm(true); }} aria-label={t('live.ptzSaveCurrentPosition')}>
{t('live.ptzSaveCurrentPosition')}
</button>
)
)}

{canManage && capabilities?.has_home_position && (
confirmSetHome ? (
<div style={{ color: 'white', fontSize: '11px' }}>
<span>{t('live.ptzConfirmSetHome')}</span>
<div style={{ display: 'flex', gap: '4px' }}>
<button type="button" disabled={saving} onClick={handleSetHome} aria-label={t('live.ptzSetHome')}>
{t('live.ptzSetHome')}
</button>
<button type="button" disabled={saving} onClick={() => setConfirmSetHome(false)} aria-label={t('common.cancel')}>
{t('common.cancel')}
</button>
</div>
</div>
) : (
<button type="button" onClick={() => { setShowPresetForm(false); setConfirmSetHome(true); }} aria-label={t('live.ptzSetCurrentPositionHome')}>
{t('live.ptzSetCurrentPositionHome')}
</button>
)
)}

{/* Error message */}
{error && (
<div style={{ color: '#ef4444', fontSize: '11px', textAlign: 'center' }}>
Expand Down
46 changes: 46 additions & 0 deletions web/js/components/preact/ptzActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const ptzUrl = (streamName, action) =>
`/api/streams/${encodeURIComponent(streamName)}/ptz/${action}`;

async function ptzRequest(streamName, action, options) {
const response = await fetch(ptzUrl(streamName, action), options);
if (!response.ok) {
const error = new Error(`PTZ ${action} failed (${response.status})`);
error.status = response.status;
throw error;
}

const data = await response.json();
if (data.success === false) {
throw new Error(`PTZ ${action} failed`);
}
return data;
}

export function getPtzCapabilities(streamName) {
return ptzRequest(streamName, 'capabilities');
}

export function getPtzPresets(streamName) {
return ptzRequest(streamName, 'presets');
}

export async function savePtzPreset(streamName, name, onPresets) {
const result = await ptzRequest(streamName, 'preset', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
let data;
try {
data = await getPtzPresets(streamName);
} catch (error) {
error.stage = 'refresh';
throw error;
}
onPresets(data.presets || []);
return result;
}

export function setPtzHome(streamName) {
return ptzRequest(streamName, 'set-home', { method: 'POST' });
}
11 changes: 11 additions & 0 deletions web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,17 @@
"live.ptzMoveFailed": "Move failed",
"live.ptzHomeFailed": "Home failed",
"live.ptzPresetFailed": "Preset failed",
"live.ptzSaveCurrentPosition": "Save current position as preset…",
"live.ptzPresetName": "Preset name",
"live.ptzSavePreset": "Save preset",
"live.ptzPresetSaved": "Preset saved",
"live.ptzPresetSaveFailed": "Could not save preset",
"live.ptzPresetRefreshFailed": "Preset saved, but the list could not be refreshed",
"live.ptzSetCurrentPositionHome": "Set current position as home",
"live.ptzConfirmSetHome": "Set the current position as home?",
"live.ptzSetHome": "Set home",
"live.ptzHomeSet": "Home position set",
"live.ptzSetHomeFailed": "Could not set home position",
"live.goToHomePosition": "Go to Home Position",
"live.zoom": "Zoom",
"live.speedLabel": "Speed:",
Expand Down
70 changes: 70 additions & 0 deletions web/tests/ptzActions.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { getPtzCapabilities, savePtzPreset, setPtzHome } from '../js/components/preact/ptzActions.js';

describe('PTZ position actions', () => {
afterEach(() => {
delete global.fetch;
});

test('saves a named preset and refreshes the list for immediate use', async () => {
const updatedPresets = [{ token: 'preset-42', name: 'Front door' }];
global.fetch = jest.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ success: true, token: 'preset-42' }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ presets: updatedPresets }) });
const onPresets = jest.fn();

await savePtzPreset('Front door / 1', 'Front door', onPresets);

expect(global.fetch).toHaveBeenNthCalledWith(1,
'/api/streams/Front%20door%20%2F%201/ptz/preset', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Front door' })
});
expect(global.fetch).toHaveBeenNthCalledWith(2,
'/api/streams/Front%20door%20%2F%201/ptz/presets', undefined);
expect(onPresets).toHaveBeenCalledWith(updatedPresets);
});

test('sets the current position as home', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true, json: async () => ({ success: true })
});

await setPtzHome('Lobby');

expect(global.fetch).toHaveBeenCalledWith(
'/api/streams/Lobby/ptz/set-home', { method: 'POST' });
});

test('loads the capability fields that gate the controls', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true, json: async () => ({ has_presets: false, has_home_position: true })
});

await expect(getPtzCapabilities('Lobby')).resolves.toMatchObject({
has_presets: false, has_home_position: true
});
expect(global.fetch).toHaveBeenCalledWith('/api/streams/Lobby/ptz/capabilities', undefined);
});

test('exposes a 403 so the panel can hide both actions', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 403 });
const onPresets = jest.fn();

await expect(savePtzPreset('Lobby', 'Front door', onPresets))
.rejects.toMatchObject({ status: 403 });
expect(onPresets).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledTimes(1);
});

test('does not replace presets when refresh fails after saving', async () => {
global.fetch = jest.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ success: true }) })
.mockResolvedValueOnce({ ok: false, status: 502 });
const onPresets = jest.fn();

await expect(savePtzPreset('Lobby', 'Front door', onPresets))
.rejects.toMatchObject({ status: 502, stage: 'refresh' });
expect(onPresets).not.toHaveBeenCalled();
});
});
Loading