Quellcode durchsuchen

@uppy/webcam: refactor to TypeScript (#4870)

Co-authored-by: Merlijn Vos <merlijn@soverin.net>
Antoine du Hamel vor 1 Jahr
Ursprung
Commit
aefb32533c
29 geänderte Dateien mit 747 neuen und 402 gelöschten Zeilen
  1. 1 0
      .eslintrc.js
  2. 0 9
      packages/@uppy/webcam/src/CameraIcon.jsx
  3. 19 0
      packages/@uppy/webcam/src/CameraIcon.tsx
  4. 0 119
      packages/@uppy/webcam/src/CameraScreen.jsx
  5. 164 0
      packages/@uppy/webcam/src/CameraScreen.tsx
  6. 7 1
      packages/@uppy/webcam/src/DiscardButton.tsx
  7. 0 11
      packages/@uppy/webcam/src/PermissionsScreen.jsx
  8. 28 0
      packages/@uppy/webcam/src/PermissionsScreen.tsx
  9. 30 3
      packages/@uppy/webcam/src/RecordButton.tsx
  10. 0 12
      packages/@uppy/webcam/src/RecordingLength.jsx
  11. 25 0
      packages/@uppy/webcam/src/RecordingLength.tsx
  12. 11 2
      packages/@uppy/webcam/src/SnapshotButton.tsx
  13. 12 2
      packages/@uppy/webcam/src/SubmitButton.tsx
  14. 0 22
      packages/@uppy/webcam/src/VideoSourceSelect.jsx
  15. 33 0
      packages/@uppy/webcam/src/VideoSourceSelect.tsx
  16. 32 12
      packages/@uppy/webcam/src/Webcam.test.ts
  17. 290 169
      packages/@uppy/webcam/src/Webcam.tsx
  18. 0 12
      packages/@uppy/webcam/src/formatSeconds.js
  19. 0 12
      packages/@uppy/webcam/src/formatSeconds.test.js
  20. 12 0
      packages/@uppy/webcam/src/formatSeconds.test.ts
  21. 7 0
      packages/@uppy/webcam/src/formatSeconds.ts
  22. 0 1
      packages/@uppy/webcam/src/index.js
  23. 1 0
      packages/@uppy/webcam/src/index.ts
  24. 10 5
      packages/@uppy/webcam/src/locale.ts
  25. 0 6
      packages/@uppy/webcam/src/supportsMediaRecorder.js
  26. 10 4
      packages/@uppy/webcam/src/supportsMediaRecorder.test.ts
  27. 9 0
      packages/@uppy/webcam/src/supportsMediaRecorder.ts
  28. 25 0
      packages/@uppy/webcam/tsconfig.build.json
  29. 21 0
      packages/@uppy/webcam/tsconfig.json

+ 1 - 0
.eslintrc.js

@@ -340,6 +340,7 @@ module.exports = {
     {
       files: [
         '*.test.js',
+        '*.test.ts',
         'test/endtoend/*.js',
         'bin/**.js',
       ],

+ 0 - 9
packages/@uppy/webcam/src/CameraIcon.jsx

@@ -1,9 +0,0 @@
-import { h } from 'preact'
-
-export default () => {
-  return (
-    <svg aria-hidden="true" focusable="false" fill="#0097DC" width="66" height="55" viewBox="0 0 66 55">
-      <path d="M57.3 8.433c4.59 0 8.1 3.51 8.1 8.1v29.7c0 4.59-3.51 8.1-8.1 8.1H8.7c-4.59 0-8.1-3.51-8.1-8.1v-29.7c0-4.59 3.51-8.1 8.1-8.1h9.45l4.59-7.02c.54-.54 1.35-1.08 2.16-1.08h16.2c.81 0 1.62.54 2.16 1.08l4.59 7.02h9.45zM33 14.64c-8.62 0-15.393 6.773-15.393 15.393 0 8.62 6.773 15.393 15.393 15.393 8.62 0 15.393-6.773 15.393-15.393 0-8.62-6.773-15.393-15.393-15.393zM33 40c-5.648 0-9.966-4.319-9.966-9.967 0-5.647 4.318-9.966 9.966-9.966s9.966 4.319 9.966 9.966C42.966 35.681 38.648 40 33 40z" fillRule="evenodd" />
-    </svg>
-  )
-}

+ 19 - 0
packages/@uppy/webcam/src/CameraIcon.tsx

@@ -0,0 +1,19 @@
+import { h, type ComponentChild } from 'preact'
+
+export default function CameraIcon(): ComponentChild {
+  return (
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      fill="#0097DC"
+      width="66"
+      height="55"
+      viewBox="0 0 66 55"
+    >
+      <path
+        d="M57.3 8.433c4.59 0 8.1 3.51 8.1 8.1v29.7c0 4.59-3.51 8.1-8.1 8.1H8.7c-4.59 0-8.1-3.51-8.1-8.1v-29.7c0-4.59 3.51-8.1 8.1-8.1h9.45l4.59-7.02c.54-.54 1.35-1.08 2.16-1.08h16.2c.81 0 1.62.54 2.16 1.08l4.59 7.02h9.45zM33 14.64c-8.62 0-15.393 6.773-15.393 15.393 0 8.62 6.773 15.393 15.393 15.393 8.62 0 15.393-6.773 15.393-15.393 0-8.62-6.773-15.393-15.393-15.393zM33 40c-5.648 0-9.966-4.319-9.966-9.967 0-5.647 4.318-9.966 9.966-9.966s9.966 4.319 9.966 9.966C42.966 35.681 38.648 40 33 40z"
+        fillRule="evenodd"
+      />
+    </svg>
+  )
+}

+ 0 - 119
packages/@uppy/webcam/src/CameraScreen.jsx

@@ -1,119 +0,0 @@
-/* eslint-disable jsx-a11y/media-has-caption */
-import { h, Component } from 'preact'
-import SnapshotButton from './SnapshotButton.jsx'
-import RecordButton from './RecordButton.jsx'
-import RecordingLength from './RecordingLength.jsx'
-import VideoSourceSelect from './VideoSourceSelect.jsx'
-import SubmitButton from './SubmitButton.jsx'
-import DiscardButton from './DiscardButton.jsx'
-
-function isModeAvailable (modes, mode) {
-  return modes.includes(mode)
-}
-
-class CameraScreen extends Component {
-  componentDidMount () {
-    const { onFocus } = this.props
-    onFocus()
-  }
-
-  componentWillUnmount () {
-    const { onStop } = this.props
-    onStop()
-  }
-
-  render () {
-    const {
-      src,
-      recordedVideo,
-      recording,
-      modes,
-      supportsRecording,
-      videoSources,
-      showVideoSourceDropdown,
-      showRecordingLength,
-      onSubmit,
-      i18n,
-      mirror,
-      onSnapshot,
-      onStartRecording,
-      onStopRecording,
-      onDiscardRecordedVideo,
-      recordingLengthSeconds,
-    } = this.props
-
-    const hasRecordedVideo = !!recordedVideo
-    const shouldShowRecordButton = !hasRecordedVideo && supportsRecording && (
-      isModeAvailable(modes, 'video-only')
-      || isModeAvailable(modes, 'audio-only')
-      || isModeAvailable(modes, 'video-audio')
-    )
-    const shouldShowSnapshotButton = !hasRecordedVideo && isModeAvailable(modes, 'picture')
-    const shouldShowRecordingLength = supportsRecording && showRecordingLength && !hasRecordedVideo
-    const shouldShowVideoSourceDropdown = showVideoSourceDropdown && videoSources && videoSources.length > 1
-
-    const videoProps = {
-      playsinline: true,
-    }
-
-    if (recordedVideo) {
-      videoProps.muted = false
-      videoProps.controls = true
-      videoProps.src = recordedVideo
-
-      // reset srcObject in dom. If not resetted, stream sticks in element
-      if (this.videoElement) {
-        this.videoElement.srcObject = undefined
-      }
-    } else {
-      videoProps.muted = true
-      videoProps.autoplay = true
-      videoProps.srcObject = src
-    }
-
-    return (
-      <div className="uppy uppy-Webcam-container">
-        <div className="uppy-Webcam-videoContainer">
-          <video
-            /* eslint-disable-next-line no-return-assign */
-            ref={(videoElement) => (this.videoElement = videoElement)}
-            className={`uppy-Webcam-video  ${mirror ? 'uppy-Webcam-video--mirrored' : ''}`}
-            /* eslint-disable-next-line react/jsx-props-no-spreading */
-            {...videoProps}
-          />
-        </div>
-        <div className="uppy-Webcam-footer">
-          <div className="uppy-Webcam-videoSourceContainer">
-            {shouldShowVideoSourceDropdown
-              ? VideoSourceSelect(this.props)
-              : null}
-          </div>
-          <div className="uppy-Webcam-buttonContainer">
-            {shouldShowSnapshotButton && <SnapshotButton onSnapshot={onSnapshot} i18n={i18n} />}
-
-            {shouldShowRecordButton && (
-              <RecordButton
-                recording={recording}
-                onStartRecording={onStartRecording}
-                onStopRecording={onStopRecording}
-                i18n={i18n}
-              />
-            )}
-
-            {hasRecordedVideo && <SubmitButton onSubmit={onSubmit} i18n={i18n} />}
-
-            {hasRecordedVideo && <DiscardButton onDiscard={onDiscardRecordedVideo} i18n={i18n} />}
-          </div>
-
-          <div className="uppy-Webcam-recordingLength">
-            {shouldShowRecordingLength && (
-              <RecordingLength recordingLengthSeconds={recordingLengthSeconds} i18n={i18n} />
-            )}
-          </div>
-        </div>
-      </div>
-    )
-  }
-}
-
-export default CameraScreen

+ 164 - 0
packages/@uppy/webcam/src/CameraScreen.tsx

@@ -0,0 +1,164 @@
+/* eslint-disable jsx-a11y/media-has-caption */
+import type { I18n } from '@uppy/utils/lib/Translator'
+import { h, Component, type ComponentChild } from 'preact'
+import type { HTMLAttributes } from 'preact/compat'
+import SnapshotButton from './SnapshotButton.tsx'
+import RecordButton from './RecordButton.tsx'
+import RecordingLength from './RecordingLength.tsx'
+import VideoSourceSelect, {
+  type VideoSourceSelectProps,
+} from './VideoSourceSelect.tsx'
+import SubmitButton from './SubmitButton.tsx'
+import DiscardButton from './DiscardButton.tsx'
+
+function isModeAvailable<T>(modes: T[], mode: any): mode is T {
+  return modes.includes(mode)
+}
+
+interface CameraScreenProps extends VideoSourceSelectProps {
+  onFocus: () => void
+  onStop: () => void
+
+  src: MediaStream | null
+  recording: boolean
+  modes: string[]
+  supportsRecording: boolean
+  showVideoSourceDropdown: boolean
+  showRecordingLength: boolean
+  onSubmit: () => void
+  i18n: I18n
+  mirror: boolean
+  onSnapshot: () => void
+  onStartRecording: () => void
+  onStopRecording: () => void
+  onDiscardRecordedVideo: () => void
+  recordingLengthSeconds: number
+}
+
+class CameraScreen extends Component<CameraScreenProps> {
+  private videoElement: HTMLVideoElement
+
+  refs: any
+
+  componentDidMount(): void {
+    const { onFocus } = this.props
+    onFocus()
+  }
+
+  componentWillUnmount(): void {
+    const { onStop } = this.props
+    onStop()
+  }
+
+  render(): ComponentChild {
+    const {
+      src,
+      // @ts-expect-error TODO: remove unused
+      recordedVideo,
+      recording,
+      modes,
+      supportsRecording,
+      videoSources,
+      showVideoSourceDropdown,
+      showRecordingLength,
+      onSubmit,
+      i18n,
+      mirror,
+      onSnapshot,
+      onStartRecording,
+      onStopRecording,
+      onDiscardRecordedVideo,
+      recordingLengthSeconds,
+    } = this.props
+
+    const hasRecordedVideo = !!recordedVideo
+    const shouldShowRecordButton =
+      !hasRecordedVideo &&
+      supportsRecording &&
+      (isModeAvailable(modes, 'video-only') ||
+        isModeAvailable(modes, 'audio-only') ||
+        isModeAvailable(modes, 'video-audio'))
+    const shouldShowSnapshotButton =
+      !hasRecordedVideo && isModeAvailable(modes, 'picture')
+    const shouldShowRecordingLength =
+      supportsRecording && showRecordingLength && !hasRecordedVideo
+    const shouldShowVideoSourceDropdown =
+      showVideoSourceDropdown && videoSources && videoSources.length > 1
+
+    const videoProps: HTMLAttributes<HTMLVideoElement> = {
+      playsInline: true,
+    }
+
+    if (recordedVideo) {
+      videoProps.muted = false
+      videoProps.controls = true
+      videoProps.src = recordedVideo
+
+      // reset srcObject in dom. If not resetted, stream sticks in element
+      if (this.videoElement) {
+        this.videoElement.srcObject = null
+      }
+    } else {
+      videoProps.muted = true
+      videoProps.autoPlay = true
+      // @ts-expect-error srcObject does not exist on <video> props
+      videoProps.srcObject = src
+    }
+
+    return (
+      <div className="uppy uppy-Webcam-container">
+        <div className="uppy-Webcam-videoContainer">
+          <video
+            /* eslint-disable-next-line no-return-assign */
+            ref={(videoElement) => (this.videoElement = videoElement!)}
+            className={`uppy-Webcam-video  ${
+              mirror ? 'uppy-Webcam-video--mirrored' : ''
+            }`}
+            /* eslint-disable-next-line react/jsx-props-no-spreading */
+            {...videoProps}
+          />
+        </div>
+        <div className="uppy-Webcam-footer">
+          <div className="uppy-Webcam-videoSourceContainer">
+            {shouldShowVideoSourceDropdown ?
+              VideoSourceSelect(this.props)
+            : null}
+          </div>
+          <div className="uppy-Webcam-buttonContainer">
+            {shouldShowSnapshotButton && (
+              <SnapshotButton onSnapshot={onSnapshot} i18n={i18n} />
+            )}
+
+            {shouldShowRecordButton && (
+              <RecordButton
+                recording={recording}
+                onStartRecording={onStartRecording}
+                onStopRecording={onStopRecording}
+                i18n={i18n}
+              />
+            )}
+
+            {hasRecordedVideo && (
+              <SubmitButton onSubmit={onSubmit} i18n={i18n} />
+            )}
+
+            {hasRecordedVideo && (
+              <DiscardButton onDiscard={onDiscardRecordedVideo} i18n={i18n} />
+            )}
+          </div>
+
+          <div className="uppy-Webcam-recordingLength">
+            {shouldShowRecordingLength && (
+              <RecordingLength
+                recordingLengthSeconds={recordingLengthSeconds}
+                i18n={i18n}
+              />
+            )}
+          </div>
+        </div>
+      </div>
+    )
+  }
+}
+
+export default CameraScreen

+ 7 - 1
packages/@uppy/webcam/src/DiscardButton.jsx → packages/@uppy/webcam/src/DiscardButton.tsx

@@ -1,6 +1,12 @@
+import type { I18n } from '@uppy/utils/lib/Translator'
 import { h } from 'preact'
 
-function DiscardButton ({ onDiscard, i18n }) {
+interface DiscardButtonProps {
+  onDiscard: () => void
+  i18n: I18n
+}
+
+function DiscardButton({ onDiscard, i18n }: DiscardButtonProps): JSX.Element {
   return (
     <button
       className="uppy-u-reset uppy-c-btn uppy-Webcam-button uppy-Webcam-button--discard"

+ 0 - 11
packages/@uppy/webcam/src/PermissionsScreen.jsx

@@ -1,11 +0,0 @@
-import { h } from 'preact'
-
-export default ({ icon, i18n, hasCamera }) => {
-  return (
-    <div className="uppy-Webcam-permissons">
-      <div className="uppy-Webcam-permissonsIcon">{icon()}</div>
-      <h1 className="uppy-Webcam-title">{hasCamera ? i18n('allowAccessTitle') : i18n('noCameraTitle')}</h1>
-      <p>{hasCamera ? i18n('allowAccessDescription') : i18n('noCameraDescription')}</p>
-    </div>
-  )
-}

+ 28 - 0
packages/@uppy/webcam/src/PermissionsScreen.tsx

@@ -0,0 +1,28 @@
+import type { I18n } from '@uppy/utils/lib/Translator'
+import { h, type ComponentChild } from 'preact'
+
+interface PermissionScreenProps {
+  hasCamera: boolean
+  icon: () => ComponentChild | null
+  i18n: I18n
+}
+
+export default function PermissionsScreen({
+  icon,
+  i18n,
+  hasCamera,
+}: PermissionScreenProps): JSX.Element {
+  return (
+    <div className="uppy-Webcam-permissons">
+      <div className="uppy-Webcam-permissonsIcon">{icon()}</div>
+      <h1 className="uppy-Webcam-title">
+        {hasCamera ? i18n('allowAccessTitle') : i18n('noCameraTitle')}
+      </h1>
+      <p>
+        {hasCamera ?
+          i18n('allowAccessDescription')
+        : i18n('noCameraDescription')}
+      </p>
+    </div>
+  )
+}

+ 30 - 3
packages/@uppy/webcam/src/RecordButton.jsx → packages/@uppy/webcam/src/RecordButton.tsx

@@ -1,6 +1,19 @@
+import type { I18n } from '@uppy/utils/lib/Translator'
 import { h } from 'preact'
 
-export default function RecordButton ({ recording, onStartRecording, onStopRecording, i18n }) {
+interface RecordButtonProps {
+  recording: boolean
+  onStartRecording: () => void
+  onStopRecording: () => void
+  i18n: I18n
+}
+
+export default function RecordButton({
+  recording,
+  onStartRecording,
+  onStopRecording,
+  i18n,
+}: RecordButtonProps): JSX.Element {
   if (recording) {
     return (
       <button
@@ -11,7 +24,14 @@ export default function RecordButton ({ recording, onStartRecording, onStopRecor
         onClick={onStopRecording}
         data-uppy-super-focusable
       >
-        <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="100" height="100" viewBox="0 0 100 100">
+        <svg
+          aria-hidden="true"
+          focusable="false"
+          className="uppy-c-icon"
+          width="100"
+          height="100"
+          viewBox="0 0 100 100"
+        >
           <rect x="15" y="15" width="70" height="70" />
         </svg>
       </button>
@@ -27,7 +47,14 @@ export default function RecordButton ({ recording, onStartRecording, onStopRecor
       onClick={onStartRecording}
       data-uppy-super-focusable
     >
-      <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="100" height="100" viewBox="0 0 100 100">
+      <svg
+        aria-hidden="true"
+        focusable="false"
+        className="uppy-c-icon"
+        width="100"
+        height="100"
+        viewBox="0 0 100 100"
+      >
         <circle cx="50" cy="50" r="40" />
       </svg>
     </button>

+ 0 - 12
packages/@uppy/webcam/src/RecordingLength.jsx

@@ -1,12 +0,0 @@
-import { h } from 'preact'
-import formatSeconds from './formatSeconds.js'
-
-export default function RecordingLength ({ recordingLengthSeconds, i18n }) {
-  const formattedRecordingLengthSeconds = formatSeconds(recordingLengthSeconds)
-
-  return (
-    <span aria-label={i18n('recordingLength', { recording_length: formattedRecordingLengthSeconds })}>
-      {formattedRecordingLengthSeconds}
-    </span>
-  )
-}

+ 25 - 0
packages/@uppy/webcam/src/RecordingLength.tsx

@@ -0,0 +1,25 @@
+import type { I18n } from '@uppy/utils/lib/Translator'
+import { h } from 'preact'
+import formatSeconds from './formatSeconds.ts'
+
+interface RecordingLengthProps {
+  recordingLengthSeconds: number
+  i18n: I18n
+}
+
+export default function RecordingLength({
+  recordingLengthSeconds,
+  i18n,
+}: RecordingLengthProps): JSX.Element {
+  const formattedRecordingLengthSeconds = formatSeconds(recordingLengthSeconds)
+
+  return (
+    <span
+      aria-label={i18n('recordingLength', {
+        recording_length: formattedRecordingLengthSeconds,
+      })}
+    >
+      {formattedRecordingLengthSeconds}
+    </span>
+  )
+}

+ 11 - 2
packages/@uppy/webcam/src/SnapshotButton.jsx → packages/@uppy/webcam/src/SnapshotButton.tsx

@@ -1,7 +1,16 @@
+import type { I18n } from '@uppy/utils/lib/Translator'
 import { h } from 'preact'
-import CameraIcon from './CameraIcon.jsx'
+import CameraIcon from './CameraIcon.tsx'
 
-export default ({ onSnapshot, i18n }) => {
+interface SnapshotButtonProps {
+  onSnapshot: () => void
+  i18n: I18n
+}
+
+export default function SnapshotButton({
+  onSnapshot,
+  i18n,
+}: SnapshotButtonProps): JSX.Element {
   return (
     <button
       className="uppy-u-reset uppy-c-btn uppy-Webcam-button uppy-Webcam-button--picture"

+ 12 - 2
packages/@uppy/webcam/src/SubmitButton.jsx → packages/@uppy/webcam/src/SubmitButton.tsx

@@ -1,6 +1,12 @@
+import type { I18n } from '@uppy/utils/lib/Translator'
 import { h } from 'preact'
 
-function SubmitButton ({ onSubmit, i18n }) {
+interface SubmitButtonProps {
+  onSubmit: () => void
+  i18n: I18n
+}
+
+function SubmitButton({ onSubmit, i18n }: SubmitButtonProps): JSX.Element {
   return (
     <button
       className="uppy-u-reset uppy-c-btn uppy-Webcam-button uppy-Webcam-button--submit"
@@ -19,7 +25,11 @@ function SubmitButton ({ onSubmit, i18n }) {
         focusable="false"
         className="uppy-c-icon"
       >
-        <path fill="#fff" fillRule="nonzero" d="M10.66 0L12 1.31 4.136 9 0 4.956l1.34-1.31L4.136 6.38z" />
+        <path
+          fill="#fff"
+          fillRule="nonzero"
+          d="M10.66 0L12 1.31 4.136 9 0 4.956l1.34-1.31L4.136 6.38z"
+        />
       </svg>
     </button>
   )

+ 0 - 22
packages/@uppy/webcam/src/VideoSourceSelect.jsx

@@ -1,22 +0,0 @@
-import { h } from 'preact'
-
-export default ({ currentDeviceId, videoSources, onChangeVideoSource }) => {
-  return (
-    <div className="uppy-Webcam-videoSource">
-      <select
-        className="uppy-u-reset uppy-Webcam-videoSource-select"
-        onChange={(event) => { onChangeVideoSource(event.target.value) }}
-      >
-        {videoSources.map((videoSource) => (
-          <option
-            key={videoSource.deviceId}
-            value={videoSource.deviceId}
-            selected={videoSource.deviceId === currentDeviceId}
-          >
-            {videoSource.label}
-          </option>
-        ))}
-      </select>
-    </div>
-  )
-}

+ 33 - 0
packages/@uppy/webcam/src/VideoSourceSelect.tsx

@@ -0,0 +1,33 @@
+import { h, type ComponentChild } from 'preact'
+
+export interface VideoSourceSelectProps {
+  currentDeviceId: string | null
+  videoSources: MediaDeviceInfo[]
+  onChangeVideoSource: (deviceId: string) => void
+}
+export default function VideoSourceSelect({
+  currentDeviceId,
+  videoSources,
+  onChangeVideoSource,
+}: VideoSourceSelectProps): ComponentChild {
+  return (
+    <div className="uppy-Webcam-videoSource">
+      <select
+        className="uppy-u-reset uppy-Webcam-videoSource-select"
+        onChange={(event) => {
+          onChangeVideoSource((event.target as HTMLInputElement).value)
+        }}
+      >
+        {videoSources.map((videoSource) => (
+          <option
+            key={videoSource.deviceId}
+            value={videoSource.deviceId}
+            selected={videoSource.deviceId === currentDeviceId}
+          >
+            {videoSource.label}
+          </option>
+        ))}
+      </select>
+    </div>
+  )
+}

+ 32 - 12
packages/@uppy/webcam/src/Webcam.test.js → packages/@uppy/webcam/src/Webcam.test.ts

@@ -1,43 +1,55 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
 import { describe, expect, it } from 'vitest'
 import Uppy from '@uppy/core'
-import Webcam from '../lib/index.js'
+import Webcam from './index.ts'
 
 describe('Webcam', () => {
   describe('_getMediaRecorderOptions', () => {
     it('should not have a mimeType set if no preferences given', () => {
+      // @ts-ignore
       globalThis.MediaRecorder = {
         isTypeSupported: () => true,
       }
 
-      const uppy = new Uppy().use(Webcam)
+      const uppy = new Uppy<any, any>().use(Webcam)
       expect(
-        uppy.getPlugin('Webcam').getMediaRecorderOptions().mimeType,
+        (uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
+          .mimeType,
       ).not.toBeDefined()
     })
 
     it('should use preferredVideoMimeType', () => {
+      // @ts-ignore
       globalThis.MediaRecorder = {
         isTypeSupported: (ty) => ty === 'video/webm',
       }
 
-      const uppy = new Uppy().use(Webcam, { preferredVideoMimeType: 'video/webm' })
+      const uppy = new Uppy().use(Webcam, {
+        preferredVideoMimeType: 'video/webm',
+      })
       expect(
-        uppy.getPlugin('Webcam').getMediaRecorderOptions().mimeType,
+        (uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
+          .mimeType,
       ).toEqual('video/webm')
     })
 
     it('should not use preferredVideoMimeType if it is not supported', () => {
+      // @ts-ignore
       globalThis.MediaRecorder = {
         isTypeSupported: (ty) => ty === 'video/webm',
       }
 
-      const uppy = new Uppy().use(Webcam, { preferredVideoMimeType: 'video/mp4' })
+      const uppy = new Uppy().use(Webcam, {
+        preferredVideoMimeType: 'video/mp4',
+      })
       expect(
-        uppy.getPlugin('Webcam').getMediaRecorderOptions().mimeType,
+        (uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
+          .mimeType,
       ).not.toBeDefined()
     })
 
     it('should pick type based on `allowedFileTypes`', () => {
+      // @ts-ignore
       globalThis.MediaRecorder = {
         isTypeSupported: () => true,
       }
@@ -46,11 +58,13 @@ describe('Webcam', () => {
         restrictions: { allowedFileTypes: ['video/mp4', 'video/webm'] },
       }).use(Webcam)
       expect(
-        uppy.getPlugin('Webcam').getMediaRecorderOptions().mimeType,
+        (uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
+          .mimeType,
       ).toEqual('video/mp4')
     })
 
     it('should use first supported type from allowedFileTypes', () => {
+      // @ts-ignore
       globalThis.MediaRecorder = {
         isTypeSupported: (ty) => ty === 'video/webm',
       }
@@ -59,25 +73,30 @@ describe('Webcam', () => {
         restrictions: { allowedFileTypes: ['video/mp4', 'video/webm'] },
       }).use(Webcam)
       expect(
-        uppy.getPlugin('Webcam').getMediaRecorderOptions().mimeType,
+        (uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
+          .mimeType,
       ).toEqual('video/webm')
     })
 
     it('should prefer preferredVideoMimeType over allowedFileTypes', () => {
+      // @ts-ignore
       globalThis.MediaRecorder = {
         isTypeSupported: () => true,
       }
 
       const uppy = new Uppy({
         restrictions: { allowedFileTypes: ['video/mp4', 'video/webm'] },
+      }).use(Webcam, {
+        preferredVideoMimeType: 'video/webm',
       })
-        .use(Webcam, { preferredVideoMimeType: 'video/webm' })
       expect(
-        uppy.getPlugin('Webcam').getMediaRecorderOptions().mimeType,
+        (uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
+          .mimeType,
       ).toEqual('video/webm')
     })
 
     it('should not use allowedFileTypes if they are unsupported', () => {
+      // @ts-ignore
       globalThis.MediaRecorder = {
         isTypeSupported: () => false,
       }
@@ -86,7 +105,8 @@ describe('Webcam', () => {
         restrictions: { allowedFileTypes: ['video/mp4', 'video/webm'] },
       }).use(Webcam)
       expect(
-        uppy.getPlugin('Webcam').getMediaRecorderOptions().mimeType,
+        (uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
+          .mimeType,
       ).toEqual(undefined)
     })
   })

+ 290 - 169
packages/@uppy/webcam/src/Webcam.jsx → packages/@uppy/webcam/src/Webcam.tsx

@@ -1,73 +1,146 @@
-import { h } from 'preact'
+import { h, type ComponentChild } from 'preact'
 
 import { UIPlugin } from '@uppy/core'
+import type { Uppy, UIPluginOptions } from '@uppy/core'
+import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts'
+import type {
+  Body,
+  Meta,
+  MinimalRequiredUppyFile,
+} from '@uppy/utils/lib/UppyFile.ts'
+import type { PluginTarget } from '@uppy/core/lib/UIPlugin.ts'
 import getFileTypeExtension from '@uppy/utils/lib/getFileTypeExtension'
 import mimeTypes from '@uppy/utils/lib/mimeTypes'
 import isMobile from 'is-mobile'
 import canvasToBlob from '@uppy/utils/lib/canvasToBlob'
-import supportsMediaRecorder from './supportsMediaRecorder.js'
-import CameraIcon from './CameraIcon.jsx'
-import CameraScreen from './CameraScreen.jsx'
-import PermissionsScreen from './PermissionsScreen.jsx'
-
+import supportsMediaRecorder from './supportsMediaRecorder.ts'
+import CameraIcon from './CameraIcon.tsx'
+import CameraScreen from './CameraScreen.tsx'
+import PermissionsScreen from './PermissionsScreen.tsx'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore We don't want TS to generate types for the package.json
 import packageJson from '../package.json'
-import locale from './locale.js'
+import locale from './locale.ts'
 
 /**
  * Normalize a MIME type or file extension into a MIME type.
  *
- * @param {string} fileType - MIME type or a file extension prefixed with `.`.
- * @returns {string|undefined} The MIME type or `undefined` if the fileType is an extension and is not known.
+ * @param fileType - MIME type or a file extension prefixed with `.`.
+ * @returns The MIME type or `undefined` if the fileType is an extension and is not known.
  */
-function toMimeType (fileType) {
+function toMimeType(fileType: string): string | undefined {
   if (fileType[0] === '.') {
-    return mimeTypes[fileType.slice(1)]
+    return mimeTypes[fileType.slice(1) as keyof typeof mimeTypes]
   }
   return fileType
 }
 
 /**
  * Is this MIME type a video?
- *
- * @param {string} mimeType - MIME type.
- * @returns {boolean}
  */
-function isVideoMimeType (mimeType) {
-  return /^video\/[^*]+$/.test(mimeType)
+function isVideoMimeType(mimeType?: string): boolean {
+  return /^video\/[^*]+$/.test(mimeType!)
 }
 
 /**
  * Is this MIME type an image?
- *
- * @param {string} mimeType - MIME type.
- * @returns {boolean}
  */
-function isImageMimeType (mimeType) {
-  return /^image\/[^*]+$/.test(mimeType)
+function isImageMimeType(mimeType?: string): boolean {
+  return /^image\/[^*]+$/.test(mimeType!)
 }
 
-function getMediaDevices () {
+function getMediaDevices() {
   // bug in the compatibility data
   // eslint-disable-next-line compat/compat
   return navigator.mediaDevices
 }
 
-function isModeAvailable (modes, mode) {
-  return modes.includes(mode)
+function isModeAvailable<T>(modes: T[], mode: unknown): mode is T {
+  return modes.includes(mode as T)
+}
+
+interface WebcamOptions<M extends Meta, B extends Body>
+  extends UIPluginOptions {
+  target?: PluginTarget<M, B>
+  onBeforeSnapshot?: () => Promise<void>
+  countdown?: number | false
+  modes?: Array<'video-audio' | 'video-only' | 'audio-only' | 'picture'>
+  mirror?: boolean
+  showVideoSourceDropdown?: boolean
+  /** @deprecated */
+  facingMode?: MediaTrackConstraints['facingMode'] // @TODO: remove in the next major
+  title?: string
+  videoConstraints?: MediaTrackConstraints
+  showRecordingLength?: boolean
+  preferredImageMimeType?: string | null
+  preferredVideoMimeType?: string | null
+  mobileNativeCamera?: boolean
+}
+
+interface WebcamState {
+  hasCamera: boolean
+  cameraReady: boolean
+  cameraError: null
+  recordingLengthSeconds: number
+  videoSources: MediaDeviceInfo[]
+  currentDeviceId: null | string
+  isRecording: boolean
+  [key: string]: unknown
 }
 
+// set default options
+const defaultOptions = {
+  onBeforeSnapshot: () => Promise.resolve(),
+  countdown: false,
+  modes: ['video-audio', 'video-only', 'audio-only', 'picture'] as any,
+  mirror: true,
+  showVideoSourceDropdown: false,
+  facingMode: 'user', // @TODO: remove in the next major
+  preferredImageMimeType: null,
+  preferredVideoMimeType: null,
+  showRecordingLength: false,
+  mobileNativeCamera: isMobile({ tablet: true }),
+} satisfies WebcamOptions<any, any>
+
 /**
  * Webcam
  */
-export default class Webcam extends UIPlugin {
+export default class Webcam<M extends Meta, B extends Body> extends UIPlugin<
+  DefinePluginOpts<WebcamOptions<M, B>, keyof typeof defaultOptions>,
+  M,
+  B,
+  WebcamState
+> {
   static VERSION = packageJson.version
 
   // enableMirror is used to toggle mirroring, for instance when discarding the video,
   // while `opts.mirror` is used to remember the initial user setting
   #enableMirror
 
-  constructor (uppy, opts) {
-    super(uppy, opts)
+  private mediaDevices
+
+  private supportsUserMedia
+
+  private protocol: 'http' | 'https'
+
+  private capturedMediaFile: MinimalRequiredUppyFile<M, B> | null
+
+  private icon: () => JSX.Element
+
+  private webcamActive
+
+  private stream: MediaStream | null
+
+  private recorder: MediaRecorder | null
+
+  private recordingChunks: Blob[] | null
+
+  private recordingLengthTimer: ReturnType<typeof setInterval>
+
+  private captureInProgress: boolean
+
+  constructor(uppy: Uppy<M, B>, opts?: WebcamOptions<M, B>) {
+    super(uppy, { ...defaultOptions, ...opts })
     this.mediaDevices = getMediaDevices()
     this.supportsUserMedia = !!this.mediaDevices
     // eslint-disable-next-line no-restricted-globals
@@ -76,34 +149,23 @@ export default class Webcam extends UIPlugin {
     this.type = 'acquirer'
     this.capturedMediaFile = null
     this.icon = () => (
-      <svg aria-hidden="true" focusable="false" width="32" height="32" viewBox="0 0 32 32">
-        <path d="M23.5 9.5c1.417 0 2.5 1.083 2.5 2.5v9.167c0 1.416-1.083 2.5-2.5 2.5h-15c-1.417 0-2.5-1.084-2.5-2.5V12c0-1.417 1.083-2.5 2.5-2.5h2.917l1.416-2.167C13 7.167 13.25 7 13.5 7h5c.25 0 .5.167.667.333L20.583 9.5H23.5zM16 11.417a4.706 4.706 0 00-4.75 4.75 4.704 4.704 0 004.75 4.75 4.703 4.703 0 004.75-4.75c0-2.663-2.09-4.75-4.75-4.75zm0 7.825c-1.744 0-3.076-1.332-3.076-3.074 0-1.745 1.333-3.077 3.076-3.077 1.744 0 3.074 1.333 3.074 3.076s-1.33 3.075-3.074 3.075z" fill="#02B383" fillRule="nonzero" />
+      <svg
+        aria-hidden="true"
+        focusable="false"
+        width="32"
+        height="32"
+        viewBox="0 0 32 32"
+      >
+        <path
+          d="M23.5 9.5c1.417 0 2.5 1.083 2.5 2.5v9.167c0 1.416-1.083 2.5-2.5 2.5h-15c-1.417 0-2.5-1.084-2.5-2.5V12c0-1.417 1.083-2.5 2.5-2.5h2.917l1.416-2.167C13 7.167 13.25 7 13.5 7h5c.25 0 .5.167.667.333L20.583 9.5H23.5zM16 11.417a4.706 4.706 0 00-4.75 4.75 4.704 4.704 0 004.75 4.75 4.703 4.703 0 004.75-4.75c0-2.663-2.09-4.75-4.75-4.75zm0 7.825c-1.744 0-3.076-1.332-3.076-3.074 0-1.745 1.333-3.077 3.076-3.077 1.744 0 3.074 1.333 3.074 3.076s-1.33 3.075-3.074 3.075z"
+          fill="#02B383"
+          fillRule="nonzero"
+        />
       </svg>
     )
 
     this.defaultLocale = locale
 
-    // set default options
-    const defaultOptions = {
-      onBeforeSnapshot: () => Promise.resolve(),
-      countdown: false,
-      modes: [
-        'video-audio',
-        'video-only',
-        'audio-only',
-        'picture',
-      ],
-      mirror: true,
-      showVideoSourceDropdown: false,
-      facingMode: 'user', // @TODO: remove in the next major
-      videoConstraints: undefined,
-      preferredImageMimeType: null,
-      preferredVideoMimeType: null,
-      showRecordingLength: false,
-      mobileNativeCamera: isMobile({ tablet: true }),
-    }
-
-    this.opts = { ...defaultOptions, ...opts }
     this.i18nInit()
     this.title = this.i18n('pluginNameCamera')
 
@@ -141,7 +203,7 @@ export default class Webcam extends UIPlugin {
     })
   }
 
-  setOptions (newOpts) {
+  setOptions(newOpts: Partial<WebcamOptions<M, B>>): void {
     super.setOptions({
       ...newOpts,
       videoConstraints: {
@@ -152,33 +214,38 @@ export default class Webcam extends UIPlugin {
     })
   }
 
-  hasCameraCheck () {
+  hasCameraCheck(): Promise<boolean> {
     if (!this.mediaDevices) {
       return Promise.resolve(false)
     }
 
-    return this.mediaDevices.enumerateDevices().then(devices => {
-      return devices.some(device => device.kind === 'videoinput')
+    return this.mediaDevices.enumerateDevices().then((devices) => {
+      return devices.some((device) => device.kind === 'videoinput')
     })
   }
 
-  isAudioOnly () {
+  isAudioOnly(): boolean {
     return this.opts.modes.length === 1 && this.opts.modes[0] === 'audio-only'
   }
 
-  getConstraints (deviceId = null) {
-    const acceptsAudio = this.opts.modes.indexOf('video-audio') !== -1
-      || this.opts.modes.indexOf('audio-only') !== -1
-    const acceptsVideo = !this.isAudioOnly()
-        && (this.opts.modes.indexOf('video-audio') !== -1
-          || this.opts.modes.indexOf('video-only') !== -1
-          || this.opts.modes.indexOf('picture') !== -1)
+  getConstraints(deviceId: string | null = null): {
+    video: false | MediaTrackConstraints
+    audio: boolean
+  } {
+    const acceptsAudio =
+      this.opts.modes.indexOf('video-audio') !== -1 ||
+      this.opts.modes.indexOf('audio-only') !== -1
+    const acceptsVideo =
+      !this.isAudioOnly() &&
+      (this.opts.modes.indexOf('video-audio') !== -1 ||
+        this.opts.modes.indexOf('video-only') !== -1 ||
+        this.opts.modes.indexOf('picture') !== -1)
 
     const videoConstraints = {
       ...(this.opts.videoConstraints || { facingMode: this.opts.facingMode }),
       // facingMode takes precedence over deviceId, and not needed
       // when specific device is selected
-      ...(deviceId ? { deviceId, facingMode: null } : {}),
+      ...(deviceId ? { deviceId, facingMode: null as any as undefined } : {}),
     }
 
     return {
@@ -188,7 +255,11 @@ export default class Webcam extends UIPlugin {
   }
 
   // eslint-disable-next-line consistent-return
-  start (options = null) {
+  start(
+    options: {
+      deviceId: string
+    } | null = null,
+  ): Promise<never> | void {
     if (!this.supportsUserMedia) {
       return Promise.reject(new Error('Webcam access not supported'))
     }
@@ -199,20 +270,25 @@ export default class Webcam extends UIPlugin {
       this.#enableMirror = true
     }
 
-    const constraints = this.getConstraints(options && options.deviceId ? options.deviceId : null)
+    const constraints = this.getConstraints(options?.deviceId)
 
-    this.hasCameraCheck().then(hasCamera => {
+    // TODO: add a return and/or convert this to async/await
+    this.hasCameraCheck().then((hasCamera) => {
       this.setPluginState({
         hasCamera,
       })
 
       // ask user for access to their camera
-      return this.mediaDevices.getUserMedia(constraints)
+      return this.mediaDevices
+        .getUserMedia(constraints)
         .then((stream) => {
           this.stream = stream
 
           let currentDeviceId = null
-          const tracks = this.isAudioOnly() ? stream.getAudioTracks() : stream.getVideoTracks()
+          const tracks =
+            this.isAudioOnly() ?
+              stream.getAudioTracks()
+            : stream.getVideoTracks()
 
           if (!options || !options.deviceId) {
             currentDeviceId = tracks[0].getSettings().deviceId
@@ -242,27 +318,28 @@ export default class Webcam extends UIPlugin {
     })
   }
 
-  /**
-   * @returns {object}
-   */
-  getMediaRecorderOptions () {
-    const options = {}
+  getMediaRecorderOptions(): { mimeType?: string } {
+    const options: { mimeType?: string } = {}
 
     // Try to use the `opts.preferredVideoMimeType` or one of the `allowedFileTypes` for the recording.
     // If the browser doesn't support it, we'll fall back to the browser default instead.
     // Safari doesn't have the `isTypeSupported` API.
     if (MediaRecorder.isTypeSupported) {
       const { restrictions } = this.uppy.opts
-      let preferredVideoMimeTypes = []
+      let preferredVideoMimeTypes: Array<string | undefined> = []
       if (this.opts.preferredVideoMimeType) {
         preferredVideoMimeTypes = [this.opts.preferredVideoMimeType]
       } else if (restrictions.allowedFileTypes) {
-        preferredVideoMimeTypes = restrictions.allowedFileTypes.map(toMimeType).filter(isVideoMimeType)
+        preferredVideoMimeTypes = restrictions.allowedFileTypes
+          .map(toMimeType)
+          .filter(isVideoMimeType)
       }
 
-      const filterSupportedTypes = (candidateType) => MediaRecorder.isTypeSupported(candidateType)
-        && getFileTypeExtension(candidateType)
-      const acceptableMimeTypes = preferredVideoMimeTypes.filter(filterSupportedTypes)
+      const filterSupportedTypes = (candidateType?: string) =>
+        MediaRecorder.isTypeSupported(candidateType!) &&
+        getFileTypeExtension(candidateType!)
+      const acceptableMimeTypes =
+        preferredVideoMimeTypes.filter(filterSupportedTypes)
 
       if (acceptableMimeTypes.length > 0) {
         // eslint-disable-next-line prefer-destructuring
@@ -273,24 +350,37 @@ export default class Webcam extends UIPlugin {
     return options
   }
 
-  startRecording () {
+  startRecording(): void {
     // only used if supportsMediaRecorder() returned true
     // eslint-disable-next-line compat/compat
-    this.recorder = new MediaRecorder(this.stream, this.getMediaRecorderOptions())
+    this.recorder = new MediaRecorder(
+      this.stream!,
+      this.getMediaRecorderOptions(),
+    )
     this.recordingChunks = []
     let stoppingBecauseOfMaxSize = false
     this.recorder.addEventListener('dataavailable', (event) => {
-      this.recordingChunks.push(event.data)
+      this.recordingChunks!.push(event.data)
 
       const { restrictions } = this.uppy.opts
-      if (this.recordingChunks.length > 1
-          && restrictions.maxFileSize != null
-          && !stoppingBecauseOfMaxSize) {
-        const totalSize = this.recordingChunks.reduce((acc, chunk) => acc + chunk.size, 0)
+      if (
+        this.recordingChunks!.length > 1 &&
+        restrictions.maxFileSize != null &&
+        !stoppingBecauseOfMaxSize
+      ) {
+        const totalSize = this.recordingChunks!.reduce(
+          (acc, chunk) => acc + chunk.size,
+          0,
+        )
         // Exclude the initial chunk from the average size calculation because it is likely to be a very small outlier
-        const averageChunkSize = (totalSize - this.recordingChunks[0].size) / (this.recordingChunks.length - 1)
+        const averageChunkSize =
+          (totalSize - this.recordingChunks![0].size) /
+          (this.recordingChunks!.length - 1)
         const expectedEndChunkSize = averageChunkSize * 3
-        const maxSize = Math.max(0, restrictions.maxFileSize - expectedEndChunkSize)
+        const maxSize = Math.max(
+          0,
+          restrictions.maxFileSize - expectedEndChunkSize,
+        )
 
         if (totalSize > maxSize) {
           stoppingBecauseOfMaxSize = true
@@ -307,8 +397,11 @@ export default class Webcam extends UIPlugin {
     if (this.opts.showRecordingLength) {
       // Start the recordingLengthTimer if we are showing the recording length.
       this.recordingLengthTimer = setInterval(() => {
-        const currentRecordingLength = this.getPluginState().recordingLengthSeconds
-        this.setPluginState({ recordingLengthSeconds: currentRecordingLength + 1 })
+        const currentRecordingLength =
+          this.getPluginState().recordingLengthSeconds
+        this.setPluginState({
+          recordingLengthSeconds: currentRecordingLength + 1,
+        })
       }, 1000)
     }
 
@@ -317,12 +410,12 @@ export default class Webcam extends UIPlugin {
     })
   }
 
-  stopRecording () {
-    const stopped = new Promise((resolve) => {
-      this.recorder.addEventListener('stop', () => {
+  stopRecording(): Promise<void> {
+    const stopped = new Promise<void>((resolve) => {
+      this.recorder!.addEventListener('stop', () => {
         resolve()
       })
-      this.recorder.stop()
+      this.recorder!.stop()
 
       if (this.opts.showRecordingLength) {
         // Stop the recordingLengthTimer if we are showing the recording length.
@@ -331,37 +424,43 @@ export default class Webcam extends UIPlugin {
       }
     })
 
-    return stopped.then(() => {
-      this.setPluginState({
-        isRecording: false,
-      })
-      return this.getVideo()
-    }).then((file) => {
-      try {
-        this.capturedMediaFile = file
-        // create object url for capture result preview
+    return stopped
+      .then(() => {
         this.setPluginState({
-          // eslint-disable-next-line compat/compat
-          recordedVideo: URL.createObjectURL(file.data),
+          isRecording: false,
         })
-        this.#enableMirror = false
-      } catch (err) {
-        // Logging the error, exept restrictions, which is handled in Core
-        if (!err.isRestriction) {
-          this.uppy.log(err)
+        return this.getVideo()
+      })
+      .then((file) => {
+        try {
+          this.capturedMediaFile = file
+          // create object url for capture result preview
+          this.setPluginState({
+            // eslint-disable-next-line compat/compat
+            recordedVideo: URL.createObjectURL(file.data as Blob),
+          })
+          this.#enableMirror = false
+        } catch (err) {
+          // Logging the error, exept restrictions, which is handled in Core
+          if (!err.isRestriction) {
+            this.uppy.log(err)
+          }
         }
-      }
-    }).then(() => {
-      this.recordingChunks = null
-      this.recorder = null
-    }, (error) => {
-      this.recordingChunks = null
-      this.recorder = null
-      throw error
-    })
+      })
+      .then(
+        () => {
+          this.recordingChunks = null
+          this.recorder = null
+        },
+        (error) => {
+          this.recordingChunks = null
+          this.recorder = null
+          throw error
+        },
+      )
   }
 
-  discardRecordedVideo () {
+  discardRecordedVideo(): void {
     this.setPluginState({ recordedVideo: null })
 
     if (this.opts.mirror) {
@@ -371,7 +470,7 @@ export default class Webcam extends UIPlugin {
     this.capturedMediaFile = null
   }
 
-  submit () {
+  submit(): void {
     try {
       if (this.capturedMediaFile) {
         this.uppy.addFile(this.capturedMediaFile)
@@ -384,7 +483,7 @@ export default class Webcam extends UIPlugin {
     }
   }
 
-  async stop () {
+  async stop(): Promise<void> {
     if (this.stream) {
       const audioTracks = this.stream.getAudioTracks()
       const videoTracks = this.stream.getVideoTracks()
@@ -394,8 +493,8 @@ export default class Webcam extends UIPlugin {
 
     if (this.recorder) {
       await new Promise((resolve) => {
-        this.recorder.addEventListener('stop', resolve, { once: true })
-        this.recorder.stop()
+        this.recorder!.addEventListener('stop', resolve, { once: true })
+        this.recorder!.stop()
 
         if (this.opts.showRecordingLength) {
           clearInterval(this.recordingLengthTimer)
@@ -415,11 +514,11 @@ export default class Webcam extends UIPlugin {
     })
   }
 
-  getVideoElement () {
-    return this.el.querySelector('.uppy-Webcam-video')
+  getVideoElement(): HTMLVideoElement | null {
+    return this.el!.querySelector('.uppy-Webcam-video')
   }
 
-  oneTwoThreeSmile () {
+  oneTwoThreeSmile(): Promise<void> {
     return new Promise((resolve, reject) => {
       let count = this.opts.countdown
 
@@ -431,7 +530,7 @@ export default class Webcam extends UIPlugin {
           return reject(new Error('Webcam is not active'))
         }
 
-        if (count > 0) {
+        if (count) {
           this.uppy.info(`${count}...`, 'warning', 800)
           count--
         } else {
@@ -443,37 +542,48 @@ export default class Webcam extends UIPlugin {
     })
   }
 
-  takeSnapshot () {
+  takeSnapshot(): void {
     if (this.captureInProgress) return
 
     this.captureInProgress = true
 
-    this.opts.onBeforeSnapshot().catch((err) => {
-      const message = typeof err === 'object' ? err.message : err
-      this.uppy.info(message, 'error', 5000)
-      return Promise.reject(new Error(`onBeforeSnapshot: ${message}`))
-    }).then(() => {
-      return this.getImage()
-    }).then((tagFile) => {
-      this.captureInProgress = false
-      try {
-        this.uppy.addFile(tagFile)
-      } catch (err) {
-        // Logging the error, except restrictions, which is handled in Core
-        if (!err.isRestriction) {
-          this.uppy.log(err)
-        }
-      }
-    }, (error) => {
-      this.captureInProgress = false
-      throw error
-    })
+    this.opts
+      .onBeforeSnapshot()
+      .catch((err) => {
+        const message = typeof err === 'object' ? err.message : err
+        this.uppy.info(message, 'error', 5000)
+        return Promise.reject(new Error(`onBeforeSnapshot: ${message}`))
+      })
+      .then(() => {
+        return this.getImage()
+      })
+      .then(
+        (tagFile) => {
+          this.captureInProgress = false
+          try {
+            this.uppy.addFile(tagFile)
+          } catch (err) {
+            // Logging the error, except restrictions, which is handled in Core
+            if (!err.isRestriction) {
+              this.uppy.log(err)
+            }
+          }
+        },
+        (error) => {
+          this.captureInProgress = false
+          throw error
+        },
+      )
   }
 
-  getImage () {
+  getImage(): Promise<MinimalRequiredUppyFile<M, B>> {
     const video = this.getVideoElement()
     if (!video) {
-      return Promise.reject(new Error('No video element found, likely due to the Webcam tab being closed.'))
+      return Promise.reject(
+        new Error(
+          'No video element found, likely due to the Webcam tab being closed.',
+        ),
+      )
     }
 
     const width = video.videoWidth
@@ -483,14 +593,16 @@ export default class Webcam extends UIPlugin {
     canvas.width = width
     canvas.height = height
     const ctx = canvas.getContext('2d')
-    ctx.drawImage(video, 0, 0)
+    ctx!.drawImage(video, 0, 0)
 
     const { restrictions } = this.uppy.opts
-    let preferredImageMimeTypes = []
+    let preferredImageMimeTypes: string[] = []
     if (this.opts.preferredImageMimeType) {
       preferredImageMimeTypes = [this.opts.preferredImageMimeType]
     } else if (restrictions.allowedFileTypes) {
-      preferredImageMimeTypes = restrictions.allowedFileTypes.map(toMimeType).filter(isImageMimeType)
+      preferredImageMimeTypes = restrictions.allowedFileTypes
+        .map(toMimeType)
+        .filter(isImageMimeType) as string[]
     }
 
     const mimeType = preferredImageMimeTypes[0] || 'image/jpeg'
@@ -501,26 +613,32 @@ export default class Webcam extends UIPlugin {
       return {
         source: this.id,
         name,
-        data: new Blob([blob], { type: mimeType }),
+        data: new Blob([blob!], { type: mimeType }),
         type: mimeType,
       }
     })
   }
 
-  getVideo () {
+  getVideo(): Promise<MinimalRequiredUppyFile<M, B>> {
     // Sometimes in iOS Safari, Blobs (especially the first Blob in the recordingChunks Array)
     // have empty 'type' attributes (e.g. '') so we need to find a Blob that has a defined 'type'
     // attribute in order to determine the correct MIME type.
-    const mimeType = this.recordingChunks.find(blob => blob.type?.length > 0).type
+    const mimeType = this.recordingChunks!.find(
+      (blob) => blob.type?.length > 0,
+    )!.type
 
     const fileExtension = getFileTypeExtension(mimeType)
 
     if (!fileExtension) {
-      return Promise.reject(new Error(`Could not retrieve recording: Unsupported media type "${mimeType}"`))
+      return Promise.reject(
+        new Error(
+          `Could not retrieve recording: Unsupported media type "${mimeType}"`,
+        ),
+      )
     }
 
     const name = `webcam-${Date.now()}.${fileExtension}`
-    const blob = new Blob(this.recordingChunks, { type: mimeType })
+    const blob = new Blob(this.recordingChunks!, { type: mimeType })
     const file = {
       source: this.id,
       name,
@@ -531,27 +649,27 @@ export default class Webcam extends UIPlugin {
     return Promise.resolve(file)
   }
 
-  focus () {
+  focus(): void {
     if (!this.opts.countdown) return
     setTimeout(() => {
       this.uppy.info(this.i18n('smile'), 'success', 1500)
     }, 1000)
   }
 
-  changeVideoSource (deviceId) {
+  changeVideoSource(deviceId: string): void {
     this.stop()
     this.start({ deviceId })
   }
 
-  updateVideoSources () {
-    this.mediaDevices.enumerateDevices().then(devices => {
+  updateVideoSources(): void {
+    this.mediaDevices.enumerateDevices().then((devices) => {
       this.setPluginState({
         videoSources: devices.filter((device) => device.kind === 'videoinput'),
       })
     })
   }
 
-  render () {
+  render(): ComponentChild {
     if (!this.webcamActive) {
       this.start()
     }
@@ -592,13 +710,16 @@ export default class Webcam extends UIPlugin {
     )
   }
 
-  install () {
-    const { mobileNativeCamera, modes, facingMode, videoConstraints } = this.opts
+  install(): void {
+    const { mobileNativeCamera, modes, facingMode, videoConstraints } =
+      this.opts
 
     const { target } = this.opts
     if (mobileNativeCamera && target) {
-      this.getTargetPlugin(target)?.setOptions({
-        showNativeVideoCameraButton: isModeAvailable(modes, 'video-only') || isModeAvailable(modes, 'video-audio'),
+      this.getTargetPlugin<M, B>(target)?.setOptions({
+        showNativeVideoCameraButton:
+          isModeAvailable(modes, 'video-only') ||
+          isModeAvailable(modes, 'video-audio'),
         showNativePhotoCameraButton: isModeAvailable(modes, 'picture'),
         nativeCameraFacingMode: videoConstraints?.facingMode || facingMode,
       })
@@ -640,12 +761,12 @@ export default class Webcam extends UIPlugin {
     }
   }
 
-  uninstall () {
+  uninstall(): void {
     this.stop()
     this.unmount()
   }
 
-  onUnmount () {
+  onUnmount(): void {
     this.stop()
   }
 }

+ 0 - 12
packages/@uppy/webcam/src/formatSeconds.js

@@ -1,12 +0,0 @@
-/**
- * Takes an Integer value of seconds (e.g. 83) and converts it into a human-readable formatted string (e.g. '1:23').
- *
- * @param {Integer} seconds
- * @returns {string} the formatted seconds (e.g. '1:23' for 1 minute and 23 seconds)
- *
- */
-export default function formatSeconds (seconds) {
-  return `${Math.floor(
-    seconds / 60,
-  )}:${String(seconds % 60).padStart(2, 0)}`
-}

+ 0 - 12
packages/@uppy/webcam/src/formatSeconds.test.js

@@ -1,12 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import formatSeconds from './formatSeconds.js'
-
-describe('formatSeconds', () => {
-  it('should return a value of \'0:43\' when an argument of 43 seconds is supplied', () => {
-    expect(formatSeconds(43)).toEqual('0:43')
-  })
-
-  it('should return a value of \'1:43\' when an argument of 103 seconds is supplied', () => {
-    expect(formatSeconds(103)).toEqual('1:43')
-  })
-})

+ 12 - 0
packages/@uppy/webcam/src/formatSeconds.test.ts

@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import formatSeconds from './formatSeconds.ts'
+
+describe('formatSeconds', () => {
+  it("should return a value of '0:43' when an argument of 43 seconds is supplied", () => {
+    expect(formatSeconds(43)).toEqual('0:43')
+  })
+
+  it("should return a value of '1:43' when an argument of 103 seconds is supplied", () => {
+    expect(formatSeconds(103)).toEqual('1:43')
+  })
+})

+ 7 - 0
packages/@uppy/webcam/src/formatSeconds.ts

@@ -0,0 +1,7 @@
+/**
+ * Takes an Integer value of seconds (e.g. 83) and converts it into a human-readable formatted string (e.g. '1:23').
+ *
+ */
+export default function formatSeconds(seconds: number): string {
+  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
+}

+ 0 - 1
packages/@uppy/webcam/src/index.js

@@ -1 +0,0 @@
-export { default } from './Webcam.jsx'

+ 1 - 0
packages/@uppy/webcam/src/index.ts

@@ -0,0 +1 @@
+export { default } from './Webcam.tsx'

+ 10 - 5
packages/@uppy/webcam/src/locale.js → packages/@uppy/webcam/src/locale.ts

@@ -1,9 +1,13 @@
+import type { Locale } from '@uppy/utils/lib/Translator'
+
 export default {
   strings: {
     pluginNameCamera: 'Camera',
     noCameraTitle: 'Camera Not Available',
-    noCameraDescription: 'In order to take pictures or record video, please connect a camera device',
-    recordingStoppedMaxSize: 'Recording stopped because the file size is about to exceed the limit',
+    noCameraDescription:
+      'In order to take pictures or record video, please connect a camera device',
+    recordingStoppedMaxSize:
+      'Recording stopped because the file size is about to exceed the limit',
     submitRecordedFile: 'Submit recorded file',
     discardRecordedFile: 'Discard recorded file',
     // Shown before a picture is taken when the `countdown` option is set.
@@ -23,6 +27,7 @@ export default {
     // Title on the “allow access” screen
     allowAccessTitle: 'Please allow access to your camera',
     // Description on the “allow access” screen
-    allowAccessDescription: 'In order to take pictures or record video with your camera, please allow camera access for this site.',
-  },
-}
+    allowAccessDescription:
+      'In order to take pictures or record video with your camera, please allow camera access for this site.',
+  } as Locale<0>['strings'],
+} as any as Locale

+ 0 - 6
packages/@uppy/webcam/src/supportsMediaRecorder.js

@@ -1,6 +0,0 @@
-export default function supportsMediaRecorder () {
-  /* eslint-disable compat/compat */
-  return typeof MediaRecorder === 'function' && !!MediaRecorder.prototype
-    && typeof MediaRecorder.prototype.start === 'function'
-  /* eslint-enable compat/compat */
-}

+ 10 - 4
packages/@uppy/webcam/src/supportsMediaRecorder.test.js → packages/@uppy/webcam/src/supportsMediaRecorder.test.ts

@@ -1,24 +1,30 @@
-/* eslint-disable max-classes-per-file, class-methods-use-this */
+/* eslint-disable max-classes-per-file, class-methods-use-this, @typescript-eslint/ban-ts-comment */
 import { describe, expect, it } from 'vitest'
-import supportsMediaRecorder from './supportsMediaRecorder.js'
+import supportsMediaRecorder from './supportsMediaRecorder.ts'
 
 describe('supportsMediaRecorder', () => {
   it('should return true if MediaRecorder is supported', () => {
+    // @ts-ignore
     globalThis.MediaRecorder = class MediaRecorder {
-      start () {}
+      // eslint-disable-next-line @typescript-eslint/no-empty-function
+      start() {}
     }
     expect(supportsMediaRecorder()).toEqual(true)
   })
 
   it('should return false if MediaRecorder is not supported', () => {
+    // @ts-ignore
     globalThis.MediaRecorder = undefined
     expect(supportsMediaRecorder()).toEqual(false)
 
+    // @ts-ignore
     globalThis.MediaRecorder = class MediaRecorder {}
     expect(supportsMediaRecorder()).toEqual(false)
 
+    // @ts-ignore
     globalThis.MediaRecorder = class MediaRecorder {
-      foo () {}
+      // eslint-disable-next-line @typescript-eslint/no-empty-function
+      foo() {}
     }
     expect(supportsMediaRecorder()).toEqual(false)
   })

+ 9 - 0
packages/@uppy/webcam/src/supportsMediaRecorder.ts

@@ -0,0 +1,9 @@
+export default function supportsMediaRecorder(): boolean {
+  /* eslint-disable compat/compat */
+  return (
+    typeof MediaRecorder === 'function' &&
+    !!MediaRecorder.prototype &&
+    typeof MediaRecorder.prototype.start === 'function'
+  )
+  /* eslint-enable compat/compat */
+}

+ 25 - 0
packages/@uppy/webcam/tsconfig.build.json

@@ -0,0 +1,25 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "outDir": "./lib",
+    "paths": {
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"]
+    },
+    "resolveJsonModule": false,
+    "rootDir": "./src",
+    "skipLibCheck": true
+  },
+  "include": ["./src/**/*.*"],
+  "exclude": ["./src/**/*.test.ts"],
+  "references": [
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}

+ 21 - 0
packages/@uppy/webcam/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "emitDeclarationOnly": false,
+    "noEmit": true,
+    "paths": {
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"],
+    },
+  },
+  "include": ["./package.json", "./src/**/*.*"],
+  "references": [
+    {
+      "path": "../utils/tsconfig.build.json",
+    },
+    {
+      "path": "../core/tsconfig.build.json",
+    },
+  ],
+}