UsbCameraDevicePicker.tsx 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. import { useQuery } from '@tanstack/react-query';
  2. import { RefreshCw } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import { api } from '../api/client';
  5. interface UsbCameraDevicePickerProps {
  6. printerName: string;
  7. /** The saved device path, '' when none is set. */
  8. value: string;
  9. onSelect: (device: string) => void;
  10. onEnterManually: () => void;
  11. }
  12. /** Picks a USB camera from the video devices the server can see. */
  13. export function UsbCameraDevicePicker({ printerName, value, onSelect, onEnterManually }: UsbCameraDevicePickerProps) {
  14. const { t } = useTranslation();
  15. const { data, isLoading, isError, isFetching, refetch } = useQuery({
  16. queryKey: ['usb-cameras'],
  17. queryFn: api.getUsbCameras,
  18. // A camera plugged in since the last look should show up on the next one.
  19. staleTime: 0,
  20. });
  21. const cameras = data?.cameras ?? [];
  22. // A saved camera that is unplugged stays selected rather than being cleared.
  23. const missing = value !== '' && !isLoading && !cameras.some((camera) => camera.device === value);
  24. return (
  25. <div className="space-y-1">
  26. <div className="flex items-center gap-2">
  27. <select
  28. aria-label={`${t('settings.usbCameraDevice')}: ${printerName}`}
  29. value={value}
  30. onChange={(e) => onSelect(e.target.value)}
  31. disabled={isLoading}
  32. className="flex-1 min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  33. >
  34. {isLoading ? (
  35. <option value={value}>{t('settings.usbCameraSearching')}</option>
  36. ) : (
  37. <>
  38. {value === '' && <option value="" disabled>{t('settings.usbCameraSelect')}</option>}
  39. {cameras.map((camera) => (
  40. <option key={camera.device} value={camera.device}>
  41. {camera.name} ({camera.device})
  42. </option>
  43. ))}
  44. {missing && <option value={value}>{t('settings.usbCameraNotFound', { device: value })}</option>}
  45. </>
  46. )}
  47. </select>
  48. <button
  49. type="button"
  50. onClick={() => refetch()}
  51. disabled={isFetching}
  52. aria-label={t('settings.usbCameraRefresh')}
  53. title={t('settings.usbCameraRefresh')}
  54. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-50"
  55. >
  56. <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
  57. </button>
  58. </div>
  59. {isError && <p className="text-xs text-red-400">{t('settings.usbCameraListFailed')}</p>}
  60. {data && cameras.length === 0 && (
  61. <p className="text-xs text-bambu-gray">
  62. {t('settings.usbCameraNoneFound')}{' '}
  63. <code className="text-bambu-green">devices: - /dev/video0:/dev/video0</code>
  64. </p>
  65. )}
  66. <button type="button" onClick={onEnterManually} className="text-xs text-bambu-green hover:underline">
  67. {t('settings.usbCameraEnterManually')}
  68. </button>
  69. </div>
  70. );
  71. }