@@ -34,6 +34,8 @@ import LockIcon from '@mui/icons-material/Lock'
3434import LockOpenIcon from '@mui/icons-material/LockOpen'
3535import PhotoCameraIcon from '@mui/icons-material/PhotoCamera'
3636import ReplayIcon from '@mui/icons-material/Replay'
37+ import ScreenShareIcon from '@mui/icons-material/ScreenShare'
38+ import StopIcon from '@mui/icons-material/Stop'
3739import UpdateIcon from '@mui/icons-material/Update'
3840import {
3941Alert ,
@@ -235,6 +237,15 @@ export function DiscEditor({
235237undefined as undefined | Omit < Processed , 'disc' >
236238)
237239
240+ // Screen capture state
241+ const [ captureStream , setCaptureStream ] = useState < MediaStream | null > ( null )
242+ const [ captureInterval , setCaptureInterval ] = useState < NodeJS . Timeout | null > (
243+ null
244+ )
245+
246+ // Use captureStream existence as isCapturing indicator
247+ const isCapturing = ! ! captureStream
248+
238249const { fileName, imageURL, debugImgs, texts} = scannedData ?? { }
239250const queueTotal = processedNum + outstandingNum + scanningNum
240251
@@ -250,6 +261,150 @@ export function DiscEditor({
250261queue . clearQueue ( )
251262} , [ queue ] )
252263
264+ // Screen capture functions
265+ const captureScreenshot = useCallback (
266+ ( stream :MediaStream ) => {
267+ try {
268+ // Create video element to capture frame
269+ const video = document . createElement ( 'video' )
270+ video . srcObject = stream
271+ video . muted = true
272+ video . playsInline = true
273+
274+ const handleLoadedMetadata = ( ) => {
275+ // Create canvas to capture the frame
276+ const canvas = document . createElement ( 'canvas' )
277+ const ctx = canvas . getContext ( '2d' )
278+
279+ if ( ! ctx ) {
280+ video . removeEventListener ( 'loadedmetadata' , handleLoadedMetadata )
281+ return
282+ }
283+
284+ const originalWidth = video . videoWidth
285+ const originalHeight = video . videoHeight
286+
287+ // Check if the capture is within 90% of 16:9 ratio
288+ const aspectRatio = originalWidth / originalHeight
289+ const targetRatio = 16 / 9
290+ const ratioTolerance = 0.1 // 10% tolerance
291+ const isNear16to9 =
292+ Math . abs ( aspectRatio - targetRatio ) <= targetRatio * ratioTolerance
293+
294+ let canvasWidth = originalWidth
295+ let canvasHeight = originalHeight
296+ let sourceX = 0
297+ const sourceY = 0
298+ let sourceWidth = originalWidth
299+ const sourceHeight = originalHeight
300+
301+ // If it's close to 16:9 ratio, crop to keep only the right 1/3
302+ if ( isNear16to9 ) {
303+ sourceX = Math . floor ( ( originalWidth * 2 ) / 3 ) // Start from 2/3 of the width
304+ sourceWidth = Math . floor ( originalWidth / 3 ) // Take only 1/3 of the width
305+ canvasWidth = sourceWidth
306+ canvasHeight = originalHeight
307+ }
308+
309+ canvas . width = canvasWidth
310+ canvas . height = canvasHeight
311+
312+ // Draw the video frame to canvas with cropping
313+ ctx . drawImage (
314+ video ,
315+ sourceX ,
316+ sourceY ,
317+ sourceWidth ,
318+ sourceHeight ,
319+ 0 ,
320+ 0 ,
321+ canvasWidth ,
322+ canvasHeight
323+ )
324+
325+ // Convert canvas to blob and create file
326+ canvas . toBlob ( ( blob ) => {
327+ if ( blob ) {
328+ const timestamp = new Date ( ) . toISOString ( ) . replace ( / [: .] / g, '-' )
329+ const file = new File ( [ blob ] , `screen-capture-${ timestamp } .png` , {
330+ type :'image/png' ,
331+ } )
332+
333+ // Add to scanning queue
334+ queue . addFiles ( [ { f :file , fName :file . name } ] )
335+ }
336+ } , 'image/png' )
337+
338+ // Clean up
339+ video . removeEventListener ( 'loadedmetadata' , handleLoadedMetadata )
340+ video . srcObject = null
341+ }
342+
343+ video . addEventListener ( 'loadedmetadata' , handleLoadedMetadata )
344+ video . play ( ) . catch ( console . error )
345+ } catch ( error ) {
346+ console . error ( 'Failed to capture screenshot:' , error )
347+ }
348+ } ,
349+ [ queue ]
350+ )
351+
352+ const stopScreenCapture = useCallback ( ( ) => {
353+ if ( captureInterval ) {
354+ clearInterval ( captureInterval )
355+ setCaptureInterval ( null )
356+ }
357+
358+ if ( captureStream ) {
359+ captureStream . getTracks ( ) . forEach ( ( track ) => track . stop ( ) )
360+ setCaptureStream ( null )
361+ }
362+ } , [ captureInterval , captureStream ] )
363+
364+ const startScreenCapture = async ( ) => {
365+ try {
366+ // Check if getDisplayMedia is supported
367+ if ( ! navigator . mediaDevices || ! navigator . mediaDevices . getDisplayMedia ) {
368+ alert (
369+ 'Screen capture is not supported in this browser. Please use a modern browser like Chrome, Firefox, or Edge.'
370+ )
371+ return
372+ }
373+
374+ // Request screen capture permission
375+ const stream = await navigator . mediaDevices . getDisplayMedia ( {
376+ video :true ,
377+ } )
378+
379+ setCaptureStream ( stream )
380+ onShow ( )
381+
382+ // Set up interval to capture screenshots every 5 second
383+ const interval = setInterval ( ( ) => {
384+ if ( processedNum || outstandingNum || scanningNum || scannedData ) return
385+ captureScreenshot ( stream )
386+ } , 5000 )
387+
388+ setCaptureInterval ( interval )
389+
390+ // Handle stream end (user stops sharing)
391+ stream . getVideoTracks ( ) [ 0 ] . addEventListener ( 'ended' , ( ) => {
392+ stopScreenCapture ( )
393+ } )
394+ } catch ( error ) {
395+ console . error ( 'Failed to start screen capture:' , error )
396+ if ( error instanceof Error && error . name === 'NotAllowedError' ) {
397+ alert (
398+ 'Screen capture permission was denied. Please allow screen sharing to use this feature.'
399+ )
400+ } else {
401+ alert (
402+ 'Failed to start screen capture. Please ensure you grant permission to share your screen.'
403+ )
404+ }
405+ }
406+ }
407+
253408const onUpload = useCallback (
254409( e :ChangeEvent < HTMLInputElement > ) => {
255410if ( ! e . target ) return
@@ -269,6 +424,16 @@ export function DiscEditor({
269424setDisc ( ( scannedDisc ?? { } ) as Partial < ICachedDisc > )
270425} , [ queue , processedNum , scannedData , setDisc ] )
271426
427+ // Auto-reset when duplicate is detected during capture mode
428+ if (
429+ isCapturing &&
430+ prevEditType === 'duplicate' &&
431+ disc &&
432+ Object . keys ( disc ) . length > 0
433+ ) {
434+ reset ( )
435+ }
436+
272437useEffect ( ( ) => {
273438const pasteFunc = ( e :Event ) => {
274439// Don't handle paste if targetting the edit team modal
@@ -297,6 +462,13 @@ export function DiscEditor({
297462}
298463} , [ queue ] )
299464
465+ // Cleanup screen capture on unmount
466+ useEffect ( ( ) => {
467+ return ( ) => {
468+ stopScreenCapture ( )
469+ }
470+ } , [ stopScreenCapture ] )
471+
300472return (
301473< Suspense fallback = { false } >
302474< ModalWrapper open = { show } onClose = { onCloseModal } >
@@ -458,6 +630,22 @@ export function DiscEditor({
458630</ Button >
459631</ label >
460632</ Grid >
633+ < Grid item >
634+ < Button
635+ onClick = {
636+ isCapturing
637+ ?stopScreenCapture
638+ :startScreenCapture
639+ }
640+ startIcon = {
641+ isCapturing ?< StopIcon /> :< ScreenShareIcon />
642+ }
643+ color = { isCapturing ?'error' :'primary' }
644+ variant = { 'contained' }
645+ >
646+ { isCapturing ?'Stop Capture' :'Capture Screen' }
647+ </ Button >
648+ </ Grid >
461649{ shouldShowDevComponents && debugImgs && (
462650< Grid item >
463651< DebugModal imgs = { debugImgs } />
@@ -502,6 +690,12 @@ export function DiscEditor({
502690}
503691/>
504692) }
693+ { isCapturing && (
694+ < Alert severity = "info" sx = { { mt :1 } } >
695+ Screen capture is active. Screenshots will be taken
696+ every 5 seconds.
697+ </ Alert >
698+ ) }
505699{ ! ! queueTotal && (
506700< CardThemed sx = { { pl :2 } } >
507701< Box display = "flex" alignItems = "center" >