diff --git a/web/js/components/preact/PTZControls.jsx b/web/js/components/preact/PTZControls.jsx index c6728e9a..3a5b904b 100644 --- a/web/js/components/preact/PTZControls.jsx +++ b/web/js/components/preact/PTZControls.jsx @@ -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 @@ -34,11 +36,6 @@ 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', @@ -46,11 +43,6 @@ const ptzApi = { body: JSON.stringify({ token }) }); return response.json(); - }, - - async getCapabilities(streamName) { - const response = await fetch(`/api/streams/${encodeURIComponent(streamName)}/ptz/capabilities`); - return response.json(); } }; @@ -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; @@ -184,7 +235,9 @@ export function PTZControls({ stream, isVisible = true, onClose }) { {t('live.ptzControl')} {onClose && ( + + + + ) : ( + + ) + )} + + {canManage && capabilities?.has_home_position && ( + confirmSetHome ? ( +
+ {t('live.ptzConfirmSetHome')} +
+ + +
+
+ ) : ( + + ) + )} + {/* Error message */} {error && (
diff --git a/web/js/components/preact/ptzActions.js b/web/js/components/preact/ptzActions.js new file mode 100644 index 00000000..57db9b0d --- /dev/null +++ b/web/js/components/preact/ptzActions.js @@ -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' }); +} diff --git a/web/public/locales/en.json b/web/public/locales/en.json index 8c36af1f..6493ce85 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -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:", diff --git a/web/tests/ptzActions.spec.js b/web/tests/ptzActions.spec.js new file mode 100644 index 00000000..3a3cbf9b --- /dev/null +++ b/web/tests/ptzActions.spec.js @@ -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(); + }); +});