Headless Hooks: Add useScreenCapture (#5784)

This commit is contained in:
Prakash 2025-06-19 16:12:01 +05:30 committed by GitHub
parent a83b66c866
commit b0e69cf7ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1025 additions and 222 deletions

View file

@ -11,6 +11,7 @@
"@tailwindcss/vite": "^4.0.9",
"@uppy/core": "workspace:*",
"@uppy/react": "workspace:*",
"@uppy/screen-capture": "workspace:*",
"@uppy/tus": "workspace:*",
"@uppy/webcam": "workspace:*",
"react": "^19.0.0",

View file

@ -2,156 +2,54 @@
/* eslint-disable react/button-has-type */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable react/react-in-jsx-scope */
import { useEffect, useState, useRef } from 'react'
import React, { useState, useRef } from 'react'
import {
Dropzone,
FilesGrid,
FilesList,
ProviderIcon,
UploadButton,
UppyContextProvider,
useWebcam,
useDropzone,
useFileInput,
} from '@uppy/react'
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'
import UppyWebcam from '@uppy/webcam'
import UppyScreenCapture from '@uppy/screen-capture'
import Webcam from './Webcam.tsx'
import ScreenCapture from './ScreenCapture.tsx'
import CustomDropzone from './CustomDropzone.tsx'
import './app.css'
import '@uppy/react/dist/styles.css'
interface WebcamProps {
isOpen: boolean
close: () => void
}
function Webcam({ isOpen, close }: WebcamProps) {
const {
start,
stop,
getVideoProps,
getSnapshotButtonProps,
getRecordButtonProps,
getStopRecordingButtonProps,
getSubmitButtonProps,
getDiscardButtonProps,
} = useWebcam({ onSubmit: close })
useEffect(() => {
if (isOpen) {
start()
}
return () => {
stop()
}
}, [start, stop, isOpen])
return (
<div className="p-4 max-w-lg w-full">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Camera</h2>
<button onClick={close} className="text-gray-500 hover:text-gray-700">
</button>
</div>
<video
className="border-2 w-full rounded-lg data-[uppy-mirrored=true]:scale-x-[-1]"
{...getVideoProps()}
/>
<div className="flex gap-4 mt-4">
<button
className="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...getSnapshotButtonProps()}
>
Snapshot
</button>
<button
className="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...getRecordButtonProps()}
>
Record
</button>
<button
className="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300"
{...getStopRecordingButtonProps()}
>
Stop
</button>
<button
className="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
{...getSubmitButtonProps()}
>
Submit
</button>
<button
className="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300"
{...getDiscardButtonProps()}
>
Discard
</button>
</div>
</div>
)
}
interface CustomDropzoneProps {
openWebcamModal: () => void
}
function CustomDropzone({ openWebcamModal }: CustomDropzoneProps) {
const { getRootProps, getInputProps } = useDropzone({
noClick: true,
})
const { getButtonProps, getInputProps: getFileInputProps } = useFileInput()
return (
<div>
<input {...getInputProps()} className="hidden" />
<div
{...getRootProps()}
role="button"
className="border-2 border-dashed border-gray-300 rounded-lg p-6 bg-gray-50 transition-colors duration-200"
>
<div className="flex items-center justify-center gap-4">
<input {...getFileInputProps()} className="hidden" />
<button
{...getButtonProps()}
className="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
<div className="bg-white shadow-md rounded-md p-1">
<ProviderIcon provider="device" fill="#1269cf" />
</div>
Device
</button>
<button
onClick={openWebcamModal}
className="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
<div className="bg-white shadow-md rounded-md p-1">
<ProviderIcon provider="camera" fill="#02B383" />
</div>
Webcam
</button>
</div>
</div>
</div>
)
}
function App() {
const [uppy] = useState(() =>
new Uppy()
.use(Tus, {
endpoint: 'https://tusd.tusdemo.net/files/',
})
.use(UppyScreenCapture, {
preferredVideoMimeType: 'video/webm',
displayMediaConstraints: {
video: {
width: 1280,
height: 720,
frameRate: {
ideal: 3,
max: 5,
},
},
audio: false,
},
enableScreenshots: true,
preferredImageMimeType: 'image/webp',
})
.use(UppyWebcam),
)
const webcamDialogRef = useRef<HTMLDialogElement>(null)
const screenCaptureDialogRef = useRef<HTMLDialogElement>(null)
const [isWebcamOpen, setIsWebcamOpen] = useState(false)
const [isScreenCaptureOpen, setIsScreenCaptureOpen] = useState(false)
function openWebcamModal() {
setIsWebcamOpen(true)
@ -163,6 +61,16 @@ function App() {
webcamDialogRef.current?.close()
}
function openScreenCaptureModal() {
setIsScreenCaptureOpen(true)
screenCaptureDialogRef.current?.showModal()
}
function closeScreenCaptureModal() {
setIsScreenCaptureOpen(false)
screenCaptureDialogRef.current?.close()
}
return (
<UppyContextProvider uppy={uppy}>
<main className="p-5 max-w-xl mx-auto">
@ -177,6 +85,16 @@ function App() {
<Webcam isOpen={isWebcamOpen} close={() => closeWebcamModal()} />
</dialog>
<dialog
ref={screenCaptureDialogRef}
className="backdrop:bg-gray-500/50 rounded-lg shadow-xl p-0 fixed inset-0 m-auto"
>
<ScreenCapture
isOpen={isScreenCaptureOpen}
close={() => closeScreenCaptureModal()}
/>
</dialog>
<article>
<h2 className="text-2xl my-4">With list</h2>
<Dropzone />
@ -191,7 +109,11 @@ function App() {
<article>
<h2 className="text-2xl my-4">With custom dropzone</h2>
<CustomDropzone openWebcamModal={() => openWebcamModal()} />
<CustomDropzone
openWebcamModal={() => openWebcamModal()}
openScreenCaptureModal={() => openScreenCaptureModal()}
/>
<FilesList />
</article>
</main>
</UppyContextProvider>

View file

@ -0,0 +1,63 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable react/react-in-jsx-scope */
/* eslint-disable react/button-has-type */
import { useDropzone, useFileInput, ProviderIcon } from '@uppy/react'
export interface CustomDropzoneProps {
openWebcamModal: () => void
openScreenCaptureModal: () => void
}
export function CustomDropzone({
openWebcamModal,
openScreenCaptureModal,
}: CustomDropzoneProps) {
const { getRootProps, getInputProps } = useDropzone({ noClick: true })
const { getButtonProps, getInputProps: getFileInputProps } = useFileInput()
return (
<div>
<input {...getInputProps()} className="hidden" />
<div
{...getRootProps()}
role="button"
className="border-2 border-dashed border-gray-300 rounded-lg p-6 bg-gray-50 transition-colors duration-200"
>
<div className="flex items-center justify-center gap-4">
<input {...getFileInputProps()} className="hidden" />
<button
{...getButtonProps()}
className="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
<div className="bg-white shadow-md rounded-md p-1">
<ProviderIcon provider="device" fill="#1269cf" />
</div>
Device
</button>
<button
type="button"
onClick={openWebcamModal}
className="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
<div className="bg-white shadow-md rounded-md p-1">
<ProviderIcon provider="camera" fill="#02B383" />
</div>
Webcam
</button>
<button
type="button"
onClick={openScreenCaptureModal}
className="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
<div className="bg-white shadow-md rounded-md p-1">
<ProviderIcon provider="screen-capture" fill="#FF5733" />
</div>
Screen Capture
</button>
</div>
</div>
</div>
)
}
export default CustomDropzone

View file

@ -0,0 +1,88 @@
/* eslint-disable react/jsx-props-no-spreading, react/button-has-type */
/* eslint-disable react/react-in-jsx-scope */
import React from 'react'
type ButtonProps = {
type: 'button'
onClick: () => void
disabled: boolean
}
export interface MediaCaptureProps {
title: string
close: () => void
getVideoProps: () => Record<string, unknown>
getPrimaryActionButtonProps: () => ButtonProps
primaryActionButtonLabel: string
getRecordButtonProps: () => ButtonProps
getStopRecordingButtonProps: () => ButtonProps
getSubmitButtonProps: () => ButtonProps
getDiscardButtonProps: () => ButtonProps
}
export function MediaCapture({
title,
close,
getVideoProps,
getPrimaryActionButtonProps,
primaryActionButtonLabel,
getRecordButtonProps,
getStopRecordingButtonProps,
getSubmitButtonProps,
getDiscardButtonProps,
}: MediaCaptureProps) {
return (
<div className="p-4 max-w-lg w-full">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{title}</h2>
<button
type="button"
onClick={close}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<video
className="border-2 w-full rounded-lg data-[uppy-mirrored=true]:scale-x-[-1]"
{...getVideoProps()}
>
<track kind="captions" />
</video>
<div className="flex gap-4 flex-wrap mt-4">
<button
className="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...getPrimaryActionButtonProps()}
>
{primaryActionButtonLabel}
</button>
<button
className="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...getRecordButtonProps()}
>
Record
</button>
<button
className="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300"
{...getStopRecordingButtonProps()}
>
Stop
</button>
<button
className="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
{...getSubmitButtonProps()}
>
Submit
</button>
<button
className="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300"
{...getDiscardButtonProps()}
>
Discard
</button>
</div>
</div>
)
}
export default MediaCapture

View file

@ -0,0 +1,47 @@
/* eslint-disable react/react-in-jsx-scope */
import React, { useEffect } from 'react'
import { useScreenCapture } from '@uppy/react'
import MediaCapture from './MediaCapture.tsx'
export interface ScreenCaptureProps {
isOpen: boolean
close: () => void
}
export function ScreenCapture({ isOpen, close }: ScreenCaptureProps) {
const {
start,
stop,
getVideoProps,
getScreenshotButtonProps,
getRecordButtonProps,
getStopRecordingButtonProps,
getSubmitButtonProps,
getDiscardButtonProps,
} = useScreenCapture({ onSubmit: close })
useEffect(() => {
if (isOpen) {
start()
}
return () => {
stop()
}
}, [start, stop, isOpen])
return (
<MediaCapture
title="Screen Capture"
close={close}
getVideoProps={getVideoProps}
getPrimaryActionButtonProps={getScreenshotButtonProps}
primaryActionButtonLabel="Screenshot"
getRecordButtonProps={getRecordButtonProps}
getStopRecordingButtonProps={getStopRecordingButtonProps}
getSubmitButtonProps={getSubmitButtonProps}
getDiscardButtonProps={getDiscardButtonProps}
/>
)
}
export default ScreenCapture

View file

@ -0,0 +1,48 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable react/react-in-jsx-scope */
import React, { useEffect } from 'react'
import { useWebcam } from '@uppy/react'
import MediaCapture from './MediaCapture.tsx'
export interface WebcamProps {
isOpen: boolean
close: () => void
}
export function Webcam({ isOpen, close }: WebcamProps) {
const {
start,
stop,
getVideoProps,
getSnapshotButtonProps,
getRecordButtonProps,
getStopRecordingButtonProps,
getSubmitButtonProps,
getDiscardButtonProps,
} = useWebcam({ onSubmit: close })
useEffect(() => {
if (isOpen) {
start()
}
return () => {
stop()
}
}, [start, stop, isOpen])
return (
<MediaCapture
title="Camera"
close={close}
getVideoProps={getVideoProps}
getPrimaryActionButtonProps={getSnapshotButtonProps}
primaryActionButtonLabel="Snapshot"
getRecordButtonProps={getRecordButtonProps}
getStopRecordingButtonProps={getStopRecordingButtonProps}
getSubmitButtonProps={getSubmitButtonProps}
getDiscardButtonProps={getDiscardButtonProps}
/>
)
}
export default Webcam

View file

@ -9,6 +9,7 @@
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"allowImportingTsExtensions": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,

View file

@ -16,6 +16,7 @@
"@uppy/dashboard": "workspace:*",
"@uppy/drag-drop": "workspace:*",
"@uppy/progress-bar": "workspace:*",
"@uppy/screen-capture": "workspace:*",
"@uppy/svelte": "workspace:*",
"@uppy/tus": "workspace:*",
"@uppy/webcam": "workspace:*"

View file

@ -3,9 +3,10 @@
interface Props {
openWebcamModal: () => void
openScreenCaptureModal: () => void
}
const { openWebcamModal }: Props = $props()
const { openWebcamModal, openScreenCaptureModal }: Props = $props()
const { getRootProps, getInputProps } = useDropzone({ noClick: true })
const { getButtonProps, getInputProps: getFileInputProps } = useFileInput()
@ -39,6 +40,16 @@
</div>
Webcam
</button>
<button
onclick={openScreenCaptureModal}
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
<div class="bg-white shadow-md rounded-md p-1">
<ProviderIcon provider="screen-capture" fill="#FF69B4" />
</div>
Screen Capture
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,73 @@
<script lang="ts">
type ButtonProps = Record<string, unknown>
type VideoProps = Record<string, unknown>
interface Props {
title: string
close: () => void
videoProps: VideoProps
primaryActionButtonProps: ButtonProps
primaryActionButtonLabel: string
recordButtonProps: ButtonProps
stopRecordingButtonProps: ButtonProps
submitButtonProps: ButtonProps
discardButtonProps: ButtonProps
}
const {
title,
close,
videoProps,
primaryActionButtonProps,
primaryActionButtonLabel,
recordButtonProps,
stopRecordingButtonProps,
submitButtonProps,
discardButtonProps,
}: Props = $props()
</script>
<div class="p-4 max-w-lg w-full">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">{title}</h2>
<button type="button" onclick={close} class="text-gray-500 hover:text-gray-700">
</button>
</div>
<video
class="border-2 w-full rounded-lg data-[uppy-mirrored=true]:scale-x-[-1]"
{...videoProps}
></video>
<div class="flex gap-4 flex-wrap mt-4">
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...primaryActionButtonProps}
>
{primaryActionButtonLabel}
</button>
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...recordButtonProps}
>
Record
</button>
<button
class="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300"
{...stopRecordingButtonProps}
>
Stop
</button>
<button
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
{...submitButtonProps}
>
Submit
</button>
<button
class="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300"
{...discardButtonProps}
>
Discard
</button>
</div>
</div>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import MediaCapture from './MediaCapture.svelte'
import { onDestroy, untrack } from 'svelte'
import { useScreenCapture } from '@uppy/svelte'
interface Props {
isOpen: boolean
close: () => void
}
const { isOpen, close }: Props = $props()
const screenCaptureStore = useScreenCapture({ onSubmit: close })
$effect(() => {
if (isOpen) {
// Use untrack to not trigger the effect again
untrack(() => screenCaptureStore.start())
} else {
untrack(() => screenCaptureStore.stop())
}
})
onDestroy(() => {
untrack(() => screenCaptureStore.stop())
})
</script>
<MediaCapture
title="Screen Capture"
{close}
videoProps={screenCaptureStore.getVideoProps()}
primaryActionButtonProps={screenCaptureStore.getScreenshotButtonProps()}
primaryActionButtonLabel="Screenshot"
recordButtonProps={screenCaptureStore.getRecordButtonProps()}
stopRecordingButtonProps={screenCaptureStore.getStopRecordingButtonProps()}
submitButtonProps={screenCaptureStore.getSubmitButtonProps()}
discardButtonProps={screenCaptureStore.getDiscardButtonProps()}
/>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import MediaCapture from './MediaCapture.svelte'
import { onDestroy, untrack } from 'svelte'
import { useWebcam } from '@uppy/svelte'
@ -24,47 +25,14 @@
})
</script>
<div class="p-4 max-w-lg w-full">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Camera</h2>
<button onclick={close} class="text-gray-500 hover:text-gray-700">
</button>
</div>
<video
class="border-2 w-full rounded-lg data-[uppy-mirrored=true]:scale-x-[-1]"
{...webcamStore.getVideoProps()}
></video>
<div class="flex gap-4 mt-4">
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...webcamStore.getSnapshotButtonProps()}
>
Snapshot
</button>
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
{...webcamStore.getRecordButtonProps()}
>
Record
</button>
<button
class="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300"
{...webcamStore.getStopRecordingButtonProps()}
>
Stop
</button>
<button
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
{...webcamStore.getSubmitButtonProps()}
>
Submit
</button>
<button
class="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300"
{...webcamStore.getDiscardButtonProps()}
>
Discard
</button>
</div>
</div>
<MediaCapture
title="Camera"
{close}
videoProps={webcamStore.getVideoProps()}
primaryActionButtonProps={webcamStore.getSnapshotButtonProps()}
primaryActionButtonLabel="Snapshot"
recordButtonProps={webcamStore.getRecordButtonProps()}
stopRecordingButtonProps={webcamStore.getStopRecordingButtonProps()}
submitButtonProps={webcamStore.getSubmitButtonProps()}
discardButtonProps={webcamStore.getDiscardButtonProps()}
/>

View file

@ -2,6 +2,7 @@
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'
import UppyWebcam from '@uppy/webcam'
import UppyScreenCapture from '@uppy/screen-capture'
import {
UppyContextProvider,
Dropzone,
@ -13,16 +14,21 @@
import CustomDropzone from '../components/CustomDropzone.svelte'
import Webcam from '../components/Webcam.svelte'
import ScreenCapture from '../components/ScreenCapture.svelte'
const uppy = new Uppy()
.use(Tus, {
endpoint: 'https://tusd.tusdemo.net/files/',
})
.use(UppyWebcam)
.use(UppyScreenCapture)
let webcamDialogRef: HTMLDialogElement
let isWebcamOpen = $state(false)
let screenCaptureDialogRef: HTMLDialogElement
let isScreenCaptureOpen = $state(false)
function openWebcamModal() {
isWebcamOpen = true
webcamDialogRef?.showModal()
@ -32,6 +38,16 @@
isWebcamOpen = false
webcamDialogRef?.close()
}
function openScreenCaptureModal() {
isScreenCaptureOpen = true
screenCaptureDialogRef?.showModal()
}
function closeScreenCaptureModal() {
isScreenCaptureOpen = false
screenCaptureDialogRef?.close()
}
</script>
<UppyContextProvider {uppy}>
@ -47,6 +63,13 @@
<Webcam isOpen={isWebcamOpen} close={closeWebcamModal} />
</dialog>
<dialog
bind:this={screenCaptureDialogRef}
class="backdrop:bg-gray-500/50 rounded-lg shadow-xl p-0 fixed inset-0 m-auto"
>
<ScreenCapture isOpen={isScreenCaptureOpen} close={closeScreenCaptureModal} />
</dialog>
<article>
<h2 class="text-2xl my-4">With list</h2>
<Dropzone />
@ -61,7 +84,7 @@
<article>
<h2 class="text-2xl my-4">With custom dropzone</h2>
<CustomDropzone {openWebcamModal} />
<CustomDropzone {openWebcamModal} {openScreenCaptureModal} />
</article>
</main>
</UppyContextProvider>

View file

@ -13,6 +13,7 @@
"@uppy/dashboard": "workspace:*",
"@uppy/drag-drop": "workspace:*",
"@uppy/progress-bar": "workspace:*",
"@uppy/screen-capture": "workspace:*",
"@uppy/tus": "workspace:*",
"@uppy/vue": "workspace:*",
"@uppy/webcam": "workspace:*",

View file

@ -12,21 +12,40 @@
<Webcam :isOpen="isWebcamOpen" :close="closeWebcamModal" />
</dialog>
<dialog
ref="screenCaptureDialogRef"
class="backdrop:bg-gray-500/50 rounded-lg shadow-xl p-0 fixed inset-0 m-auto"
>
<ScreenCapture
:isOpen="isScreenCaptureOpen"
:close="closeScreenCaptureModal"
/>
</dialog>
<article>
<h2 class="text-2xl my-4">With list</h2>
<Dropzone />
<Dropzone
:openWebcamModal="openWebcamModal"
:openScreenCaptureModal="openScreenCaptureModal"
/>
<FilesList />
</article>
<article>
<h2 class="text-2xl my-4">With grid</h2>
<Dropzone />
<Dropzone
:openWebcamModal="openWebcamModal"
:openScreenCaptureModal="openScreenCaptureModal"
/>
<FilesGrid :columns="2" />
</article>
<article>
<h2 class="text-2xl my-4">With custom dropzone</h2>
<CustomDropzone :openWebcamModal="openWebcamModal" />
<CustomDropzone
:openWebcamModal="openWebcamModal"
:openScreenCaptureModal="openScreenCaptureModal"
/>
</article>
</main>
</UppyContextProvider>
@ -45,10 +64,14 @@ import {
} from '@uppy/vue'
import CustomDropzone from './Dropzone.vue'
import Webcam from './Webcam.vue'
import ScreenCapture from './ScreenCapture.vue'
import UppyWebcam from '@uppy/webcam'
import UppyScreenCapture from '@uppy/screen-capture'
const webcamDialogRef = ref<HTMLDialogElement | null>(null)
const isWebcamOpen = ref(false)
const screenCaptureDialogRef = ref<HTMLDialogElement | null>(null)
const isScreenCaptureOpen = ref(false)
function openWebcamModal() {
isWebcamOpen.value = true
@ -60,12 +83,23 @@ function closeWebcamModal() {
webcamDialogRef.value?.close()
}
function openScreenCaptureModal() {
isScreenCaptureOpen.value = true
screenCaptureDialogRef.value?.showModal()
}
function closeScreenCaptureModal() {
isScreenCaptureOpen.value = false
screenCaptureDialogRef.value?.close()
}
const uppy = computed(() =>
new Uppy()
.use(Tus, {
endpoint: 'https://tusd.tusdemo.net/files/',
})
.use(UppyWebcam),
.use(UppyWebcam)
.use(UppyScreenCapture),
)
</script>

View file

@ -26,6 +26,16 @@
</div>
Webcam
</button>
<button
@click="props.openScreenCaptureModal"
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
<div class="bg-white shadow-md rounded-md p-1">
<ProviderIcon provider="screen-capture" fill="#FF69B4" />
</div>
Screen Capture
</button>
</div>
</div>
</template>
@ -35,6 +45,7 @@ import { useDropzone, useFileInput, ProviderIcon } from '@uppy/vue'
const props = defineProps<{
openWebcamModal: () => void
openScreenCaptureModal: () => void
}>()
const { getRootProps, getInputProps } = useDropzone({

View file

@ -0,0 +1,66 @@
<template>
<div class="p-4 max-w-lg w-full">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">{{ title }}</h2>
<button
type="button"
@click="close"
class="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<video
class="border-2 w-full rounded-lg data-[uppy-mirrored=true]:scale-x-[-1]"
v-bind="videoProps"
>
<track kind="captions" />
</video>
<div class="flex gap-4 flex-wrap mt-4">
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
v-bind="primaryActionButtonProps"
>
{{ primaryActionButtonLabel }}
</button>
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
v-bind="recordButtonProps"
>
Record
</button>
<button
class="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300"
v-bind="stopRecordingButtonProps"
>
Stop
</button>
<button
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
v-bind="submitButtonProps"
>
Submit
</button>
<button
class="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300"
v-bind="discardButtonProps"
>
Discard
</button>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
close: () => void
videoProps: Record<string, unknown>
primaryActionButtonProps: Record<string, unknown>
primaryActionButtonLabel: string
recordButtonProps: Record<string, unknown>
stopRecordingButtonProps: Record<string, unknown>
submitButtonProps: Record<string, unknown>
discardButtonProps: Record<string, unknown>
}>()
</script>

View file

@ -0,0 +1,46 @@
<template>
<MediaCapture
title="Screen Capture"
:close="props.close"
:video-props="screenCapture.getVideoProps()"
:primary-action-button-props="screenCapture.getScreenshotButtonProps()"
primary-action-button-label="Screenshot"
:record-button-props="screenCapture.getRecordButtonProps()"
:stop-recording-button-props="screenCapture.getStopRecordingButtonProps()"
:submit-button-props="screenCapture.getSubmitButtonProps()"
:discard-button-props="screenCapture.getDiscardButtonProps()"
/>
</template>
<script setup lang="ts">
import { useScreenCapture } from '@uppy/vue'
import { onMounted, watch } from 'vue'
import MediaCapture from './MediaCapture.vue'
const props = defineProps<{
isOpen: boolean
close: () => void
}>()
const screenCapture = useScreenCapture({ onSubmit: props.close })
watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
screenCapture.value.start()
} else {
screenCapture.value.stop()
}
},
)
onMounted(() => {
if (props.isOpen) {
screenCapture.value.start()
}
return () => {
screenCapture.value.stop()
}
})
</script>

View file

@ -1,53 +1,21 @@
<template>
<div class="p-4 max-w-lg w-full">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Camera</h2>
<button @click="props.close" class="text-gray-500 hover:text-gray-700">
</button>
</div>
<video
class="border-2 w-full rounded-lg data-[uppy-mirrored=true]:scale-x-[-1]"
v-bind="webcam.getVideoProps()"
/>
<div class="flex gap-4 mt-4">
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
v-bind="webcam.getSnapshotButtonProps()"
>
Snapshot
</button>
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-blue-300"
v-bind="webcam.getRecordButtonProps()"
>
Record
</button>
<button
class="bg-red-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-red-300"
v-bind="webcam.getStopRecordingButtonProps()"
>
Stop
</button>
<button
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
v-bind="webcam.getSubmitButtonProps()"
>
Submit
</button>
<button
class="bg-gray-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-gray-300"
v-bind="webcam.getDiscardButtonProps()"
>
Discard
</button>
</div>
</div>
<MediaCapture
title="Camera"
:close="props.close"
:video-props="webcam.getVideoProps()"
:primary-action-button-props="webcam.getSnapshotButtonProps()"
primary-action-button-label="Snapshot"
:record-button-props="webcam.getRecordButtonProps()"
:stop-recording-button-props="webcam.getStopRecordingButtonProps()"
:submit-button-props="webcam.getSubmitButtonProps()"
:discard-button-props="webcam.getDiscardButtonProps()"
/>
</template>
<script setup lang="ts">
import { useWebcam } from '@uppy/vue'
import { onMounted, watch } from 'vue'
import MediaCapture from './MediaCapture.vue'
const props = defineProps<{
isOpen: boolean

View file

@ -0,0 +1,224 @@
import type { Uppy, UppyEventMap } from '@uppy/core'
import type { ScreenCaptureState } from '@uppy/screen-capture'
import ScreenCapture from '@uppy/screen-capture'
type ButtonProps = {
type: 'button'
onClick: () => void
disabled: boolean
}
export type ScreenCaptureSnapshot = {
state: ScreenCaptureState
stop: () => void
start: () => void
getVideoProps: () => {
playsInline: boolean
autoPlay?: boolean
muted: boolean
controls?: boolean | undefined
src?: string
}
getScreenshotButtonProps: () => ButtonProps
getRecordButtonProps: () => ButtonProps
getStopRecordingButtonProps: () => ButtonProps
getSubmitButtonProps: () => ButtonProps
getDiscardButtonProps: () => ButtonProps
}
export type ScreenCaptureStore = {
subscribe: (listener: () => void) => () => void
getSnapshot: () => ScreenCaptureSnapshot
}
const videoId = 'uppy-screencapture-video'
class Subscribers {
private subscribers: Set<() => void> = new Set()
add = (listener: () => void): (() => void) => {
this.subscribers.add(listener)
return () => this.subscribers.delete(listener)
}
emit = (): void => {
for (const listener of this.subscribers) {
listener()
}
}
clear = (): void => {
this.subscribers.clear()
}
}
export function createScreenCaptureController(
uppy: Uppy,
onSubmit?: () => void,
): ScreenCaptureStore {
const plugin = uppy.getPlugin<ScreenCapture<any, any>>('ScreenCapture')
if (!plugin) {
throw new Error(
'ScreenCapture plugin is not installed. Install @uppy/screen-capture and add it to the Uppy instance with `uppy.use(ScreenCapture)`.',
)
}
const subscribers = new Subscribers()
const onStateUpdate: UppyEventMap<any, any>['state-update'] = (
prev,
next,
patch,
): void => {
const screenCapturePatch = patch?.plugins?.ScreenCapture as
| ScreenCaptureState
| undefined
if (screenCapturePatch) {
subscribers.emit()
}
}
const stop = () => {
uppy.off('state-update', onStateUpdate)
plugin.stop()
}
const start = () => {
uppy.on('state-update', onStateUpdate)
plugin.start()
}
const getVideoProps = () => {
const ref = document.getElementById(videoId) as HTMLVideoElement | null
const { status, recordedVideo, capturedScreenshotUrl } =
plugin.getPluginState()
if (status === 'captured' && recordedVideo) {
if (ref) {
ref.srcObject = null
}
return {
id: videoId,
playsInline: true,
controls: true,
muted: false,
src: recordedVideo,
autoPlay: undefined,
}
}
/*
* If the status is 'captured', we use the capturedScreenshotUrl as the poster
* for the video element. This allows us to display the screenshot as a static image
* disabling controls and autoplay helps disguise the video element
* as a result we're using the same video element for both the screenshot and the recorded video.
*/
if (status === 'captured' && capturedScreenshotUrl) {
if (ref) {
ref.srcObject = null
}
return {
id: videoId,
playsInline: true,
controls: false,
muted: false,
poster: capturedScreenshotUrl,
autoPlay: undefined,
}
}
if (
ref &&
plugin.videoStream &&
!(capturedScreenshotUrl || recordedVideo)
) {
ref.srcObject = plugin.videoStream
}
return {
id: videoId,
playsInline: true,
autoPlay: true,
muted: true,
controls: undefined,
}
}
const getScreenshotButtonProps = () => {
const { status } = plugin.getPluginState()
return {
type: 'button' as const,
onClick: async () => {
await plugin.captureScreenshot()
},
disabled: status !== 'ready',
}
}
const getRecordButtonProps = () => {
const { status } = plugin.getPluginState()
return {
type: 'button' as const,
onClick: () => {
plugin.startRecording()
},
disabled: status !== 'ready',
}
}
const getStopRecordingButtonProps = () => {
const { status } = plugin.getPluginState()
return {
type: 'button' as const,
onClick: () => {
plugin.stopRecording()
},
disabled: status !== 'recording',
}
}
const getSubmitButtonProps = () => {
const { status } = plugin.getPluginState()
return {
type: 'button' as const,
onClick: () => {
plugin.submit()
plugin.stop()
onSubmit?.()
},
disabled: !(status === 'captured'),
}
}
const getDiscardButtonProps = () => {
const { status } = plugin.getPluginState()
return {
type: 'button' as const,
onClick: () => {
plugin.discardRecordedMedia()
},
disabled: !(status === 'captured'),
}
}
let cachedState = plugin.getPluginState()
let snapshot: ScreenCaptureSnapshot = {
state: cachedState,
stop,
start,
getVideoProps,
getScreenshotButtonProps,
getRecordButtonProps,
getStopRecordingButtonProps,
getSubmitButtonProps,
getDiscardButtonProps,
}
const getSnapshot = () => {
const nextState = plugin.getPluginState()
if (nextState === cachedState) return snapshot
cachedState = nextState
snapshot = { ...snapshot, state: nextState }
return snapshot
}
return { subscribe: subscribers.add, getSnapshot }
}

View file

@ -30,6 +30,12 @@ export {
type WebcamSnapshot,
} from './hooks/webcam.js'
export {
createScreenCaptureController,
type ScreenCaptureStore,
type ScreenCaptureSnapshot,
} from './hooks/screencapture.js'
// Types and utils
export type {
UppyContext,

View file

@ -9,6 +9,7 @@ export { default as useUppyEvent } from './useUppyEvent.js'
export { useDropzone } from './useDropzone.js'
export { useWebcam } from './useWebcam.js'
export { useFileInput } from './useFileInput.js'
export { useScreenCapture } from './useScreenCapture.js'
// Headless components
export {

View file

@ -0,0 +1,27 @@
import { useMemo } from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'
import {
createScreenCaptureController,
type ScreenCaptureSnapshot,
} from '@uppy/components'
import { useUppyContext } from './headless/UppyContextProvider.js'
type ScreenCaptureProps = {
onSubmit: () => void
}
export function useScreenCapture({
onSubmit,
}: ScreenCaptureProps): ScreenCaptureSnapshot {
const { uppy } = useUppyContext()
const controller = useMemo(
() => createScreenCaptureController(uppy, onSubmit),
[uppy, onSubmit],
)
const store = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot,
)
return store
}

View file

@ -32,6 +32,13 @@ function getMediaDevices() {
const SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const
type SupportedImageType = (typeof SUPPORTED_IMAGE_TYPES)[number]
export type ScreenCaptureStatus =
| 'init'
| 'ready'
| 'recording'
| 'captured'
| 'error'
export interface ScreenCaptureOptions extends UIPluginOptions {
displayMediaConstraints?: MediaStreamConstraints
userMediaConstraints?: MediaStreamConstraints
@ -74,6 +81,7 @@ export type ScreenCaptureState = {
recordedVideo: string | null
screenRecError: string | null
capturedScreenshotUrl: string | null
status: ScreenCaptureStatus
}
export default class ScreenCapture<
@ -143,6 +151,15 @@ export default class ScreenCapture<
// initialize
this.captureActive = false
this.capturedMediaFile = null
this.setPluginState({
streamActive: false,
audioStreamActive: false,
recording: false,
recordedVideo: null,
screenRecError: null,
capturedScreenshotUrl: null,
status: 'init',
})
}
install(): null | undefined {
@ -154,6 +171,7 @@ export default class ScreenCapture<
this.setPluginState({
streamActive: false,
audioStreamActive: false,
status: 'init',
})
const { target } = this.opts
@ -216,6 +234,8 @@ export default class ScreenCapture<
this.setPluginState({
streamActive: true,
status: 'ready',
screenRecError: null,
})
return videoStream
@ -223,6 +243,7 @@ export default class ScreenCapture<
.catch((err) => {
this.setPluginState({
screenRecError: err,
status: 'error',
})
this.userDenied = true
@ -311,10 +332,12 @@ export default class ScreenCapture<
// set plugin state to recording
this.setPluginState({
recording: true,
status: 'recording',
})
})
.catch((err) => {
this.uppy.log(err, 'error')
this.setPluginState({ screenRecError: err.message, status: 'error' })
})
}
@ -330,6 +353,7 @@ export default class ScreenCapture<
// @ts-expect-error we can't know Dashboard types here
this.parent.hideAllPanels()
}
this.setPluginState({ status: 'init' })
} else if (recording) {
// stop recorder if it is active
this.uppy.log('Capture stream inactive — stop recording')
@ -371,6 +395,7 @@ export default class ScreenCapture<
this.setPluginState({
// eslint-disable-next-line compat/compat
recordedVideo: URL.createObjectURL(file.data),
status: 'captured',
})
})
.then(
@ -401,6 +426,7 @@ export default class ScreenCapture<
this.setPluginState({
recordedVideo: null,
capturedScreenshotUrl: null,
status: this.getPluginState().streamActive ? 'ready' : 'init',
})
}
@ -468,6 +494,7 @@ export default class ScreenCapture<
audioStreamActive: false,
recordedVideo: null,
capturedScreenshotUrl: null,
status: 'init',
})
this.captureActive = false
@ -573,6 +600,7 @@ export default class ScreenCapture<
const screenshotUrl = URL.createObjectURL(blob)
this.setPluginState({
capturedScreenshotUrl: screenshotUrl,
status: 'captured',
})
resolve()
} catch (err) {

View file

@ -1,2 +1,6 @@
export { default } from './ScreenCapture.jsx'
export type { ScreenCaptureOptions } from './ScreenCapture.jsx'
export type {
ScreenCaptureOptions,
ScreenCaptureState,
} from './ScreenCapture.jsx'
export { type ScreenCaptureStatus } from './ScreenCapture.jsx'

View file

@ -12,3 +12,4 @@ export * from './components/headless/generated/index.js'
export * from './useDropzone.js'
export * from './useFileInput.js'
export * from './useWebcam.svelte.js'
export * from './useScreenCapture.svelte.js'

View file

@ -0,0 +1,73 @@
import {
createScreenCaptureController,
type ScreenCaptureSnapshot,
} from '@uppy/components'
import { useExternalStore } from './useSyncExternalStore.svelte.js'
import { getUppyContext } from './components/headless/uppyContext.js'
type ScreenCaptureProps = {
onSubmit?: () => void
}
type ButtonProps = {
type: 'button'
onclick: () => void
disabled: boolean
}
type SvelteScreenCaptureSnapshot = {
state: ScreenCaptureSnapshot['state']
stop: () => void
start: () => void
getVideoProps: () => ReturnType<ScreenCaptureSnapshot['getVideoProps']>
getScreenshotButtonProps: () => ButtonProps
getRecordButtonProps: () => ButtonProps
getStopRecordingButtonProps: () => ButtonProps
getSubmitButtonProps: () => ButtonProps
getDiscardButtonProps: () => ButtonProps
}
export function useScreenCapture(props?: ScreenCaptureProps): SvelteScreenCaptureSnapshot {
const ctx = getUppyContext()
const controller = createScreenCaptureController(ctx.uppy, props?.onSubmit)
const store = useExternalStore<ScreenCaptureSnapshot>(
controller.getSnapshot,
controller.subscribe,
)
return {
get state() {
return store.value.state
},
get stop() {
return store.value.stop
},
get start() {
return store.value.start
},
get getVideoProps() {
return store.value.getVideoProps
},
getScreenshotButtonProps: () => {
const { onClick, ...rest } = store.value.getScreenshotButtonProps()
return { ...rest, onclick: onClick }
},
getRecordButtonProps: () => {
const { onClick, ...rest } = store.value.getRecordButtonProps()
return { ...rest, onclick: onClick }
},
getStopRecordingButtonProps: () => {
const { onClick, ...rest } = store.value.getStopRecordingButtonProps()
return { ...rest, onclick: onClick }
},
getSubmitButtonProps: () => {
const { onClick, ...rest } = store.value.getSubmitButtonProps()
return { ...rest, onclick: onClick }
},
getDiscardButtonProps: () => {
const { onClick, ...rest } = store.value.getDiscardButtonProps()
return { ...rest, onclick: onClick }
},
}
}

View file

@ -11,3 +11,4 @@ export * from './headless/generated/index.js'
export * from './useDropzone.js'
export * from './useFileInput.js'
export * from './useWebcam.js'
export * from './useScreenCapture.js'

View file

@ -0,0 +1,25 @@
import { type ShallowRef } from 'vue'
import {
createScreenCaptureController,
type ScreenCaptureSnapshot,
} from '@uppy/components'
import { injectUppyContext } from './headless/context-provider.js'
import { useExternalStore } from './useSyncExternalStore.js'
type ScreenCaptureProps = {
onSubmit?: () => void
}
export function useScreenCapture(
props?: ScreenCaptureProps,
): ShallowRef<ScreenCaptureSnapshot> {
const ctx = injectUppyContext()
const controller = createScreenCaptureController(ctx.uppy, props?.onSubmit)
const store = useExternalStore<ScreenCaptureSnapshot>(
controller.getSnapshot,
controller.subscribe,
)
return store
}

View file

@ -11644,6 +11644,7 @@ __metadata:
"@types/react-dom": "npm:^19.0.4"
"@uppy/core": "workspace:*"
"@uppy/react": "workspace:*"
"@uppy/screen-capture": "workspace:*"
"@uppy/tus": "workspace:*"
"@uppy/webcam": "workspace:*"
"@vitejs/plugin-react": "npm:^4.3.4"
@ -11682,6 +11683,7 @@ __metadata:
"@uppy/dashboard": "workspace:*"
"@uppy/drag-drop": "workspace:*"
"@uppy/progress-bar": "workspace:*"
"@uppy/screen-capture": "workspace:*"
"@uppy/svelte": "workspace:*"
"@uppy/tus": "workspace:*"
"@uppy/webcam": "workspace:*"
@ -11750,6 +11752,7 @@ __metadata:
"@uppy/dashboard": "workspace:*"
"@uppy/drag-drop": "workspace:*"
"@uppy/progress-bar": "workspace:*"
"@uppy/screen-capture": "workspace:*"
"@uppy/tus": "workspace:*"
"@uppy/vue": "workspace:*"
"@uppy/webcam": "workspace:*"
@ -12302,7 +12305,7 @@ __metadata:
languageName: unknown
linkType: soft
"@uppy/screen-capture@workspace:^, @uppy/screen-capture@workspace:packages/@uppy/screen-capture":
"@uppy/screen-capture@workspace:*, @uppy/screen-capture@workspace:^, @uppy/screen-capture@workspace:packages/@uppy/screen-capture":
version: 0.0.0-use.local
resolution: "@uppy/screen-capture@workspace:packages/@uppy/screen-capture"
dependencies: