Ver código fonte

ScreenCapture plugin for screenshots / screen recordings (#2132)

* Finnish locale semantics improved

* Finnish semantics improved

* Screen capture initial commit

* Fix. Removed audio constraint from video.

* ui improvements

* so Lerna doesn’t try to publish as private

* refactor, tweak icons

* remove commented code from webcam styles

* Check for MediaRecorder (hides button in Safari) and refactor everything

* update readme

* beta for now

Co-authored-by: Miika Jukakoski <miika.jukakoski@ourmind.io>
Artur Paikin 5 anos atrás
pai
commit
a6c8ef15e1

+ 2 - 0
examples/dev/Dashboard.js

@@ -7,6 +7,7 @@ const Dropbox = require('@uppy/dropbox/src')
 const GoogleDrive = require('@uppy/google-drive/src')
 const Url = require('@uppy/url/src')
 const Webcam = require('@uppy/webcam/src')
+const ScreenCapture = require('@uppy/screen-capture/src')
 const GoldenRetriever = require('@uppy/golden-retriever/src')
 const Tus = require('@uppy/tus/src')
 const AwsS3 = require('@uppy/aws-s3/src')
@@ -65,6 +66,7 @@ module.exports = () => {
     .use(OneDrive, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Url, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Webcam, { target: Dashboard })
+    .use(ScreenCapture, { target: Dashboard })
     .use(Form, { target: '#upload-form' })
 
   switch (UPLOADER) {

+ 8 - 0
package-lock.json

@@ -8655,6 +8655,13 @@
         }
       }
     },
+    "@uppy/screen-capture": {
+      "version": "file:packages/@uppy/screen-capture",
+      "requires": {
+        "@uppy/utils": "file:packages/@uppy/utils",
+        "preact": "8.2.9"
+      }
+    },
     "@uppy/status-bar": {
       "version": "file:packages/@uppy/status-bar",
       "requires": {
@@ -37642,6 +37649,7 @@
         "@uppy/progress-bar": "file:packages/@uppy/progress-bar",
         "@uppy/provider-views": "file:packages/@uppy/provider-views",
         "@uppy/redux-dev-tools": "file:packages/@uppy/redux-dev-tools",
+        "@uppy/screen-capture": "file:packages/@uppy/screen-capture",
         "@uppy/status-bar": "file:packages/@uppy/status-bar",
         "@uppy/store-default": "file:packages/@uppy/store-default",
         "@uppy/store-redux": "file:packages/@uppy/store-redux",

+ 1 - 0
package.json

@@ -70,6 +70,7 @@
     "@uppy/url": "file:packages/@uppy/url",
     "@uppy/utils": "file:packages/@uppy/utils",
     "@uppy/webcam": "file:packages/@uppy/webcam",
+    "@uppy/screen-capture": "file:packages/@uppy/screen-capture",
     "@uppy/xhr-upload": "file:packages/@uppy/xhr-upload",
     "remark-lint-uppy": "file:private/remark-lint-uppy",
     "uppy": "file:packages/uppy",

+ 10 - 0
packages/@uppy/locales/src/en_US.js

@@ -61,6 +61,8 @@ en_US.strings = {
   importFrom: 'Import from %{name}',
   loading: 'Loading...',
   logOut: 'Log out',
+  micDisabled: 'Microphone access denied by user',
+  micIsOn: 'Microphone is on',
   myDevice: 'My Device',
   noDuplicates: 'Cannot add the duplicate file \'%{fileName}\', it already exists',
   noFilesFound: 'You have no files or folders here',
@@ -77,6 +79,7 @@ en_US.strings = {
     '0': 'Processing %{smart_count} file',
     '1': 'Processing %{smart_count} files'
   },
+  recording: 'Recording',
   recordingLength: 'Recording length %{recording_length}',
   recordingStoppedMaxSize: 'Recording stopped because the file size is about to exceed the limit',
   removeFile: 'Remove file',
@@ -88,13 +91,20 @@ en_US.strings = {
   saveChanges: 'Save changes',
   selectAllFilesFromFolderNamed: 'Select all files from folder %{name}',
   selectFileNamed: 'Select file %{name}',
+  selectSourceDescription: 'In order to capture your screen, please allow access for this site.',
+  selectSourceTitle: 'Please allow access to your screen',
   selectX: {
     '0': 'Select %{smart_count}',
     '1': 'Select %{smart_count}'
   },
   smile: 'Smile!',
+  startCapturing: 'Begin screen capturing',
   startRecording: 'Begin video recording',
+  stopCapturing: 'Stop screen capturing',
   stopRecording: 'Stop video recording',
+  streamActive: 'Stream active',
+  streamPassive: 'Stream passive',
+  submitRecordedFile: 'Submit captured video',
   takePicture: 'Take a picture',
   timedOut: 'Upload stalled for %{seconds} seconds, aborting.',
   unselectAllFilesFromFolderNamed: 'Unselect all files from folder %{name}',

+ 10 - 1
packages/@uppy/locales/src/fi_FI.js

@@ -132,7 +132,16 @@ fi_FI.strings = {
     '0': 'Sinun pitää valita vähintään %{smart_count} tiedosto',
     '1': 'Sinun pitää valita vähintään %{smart_count} tiedostoa',
     '2': 'Sinun pitää valita vähintään %{smart_count} tiedostoa'
-  }
+  },
+  startCapturing: 'Aloita tallennus',
+  stopCapturing: 'Lopeta tallennus',
+  selectSourceTitle: 'Valitse kaappauksen kohde',
+  selectSourceDescription: 'Salli ruudun jako, jotta tallennus on mahdollista.',
+  submitRecordedFile: 'Hyväksy tallenne',
+  streamActive: 'Jako aktiivinen',
+  streamPassive: 'Jako passiivinen',
+  micDisabled: 'Käyttäjä on estänyt mikrofonin',
+  recording: 'Tallennetaan'
 }
 
 fi_FI.pluralize = function (n) {

+ 21 - 0
packages/@uppy/screen-capture/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 Transloadit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 38 - 0
packages/@uppy/screen-capture/README.md

@@ -0,0 +1,38 @@
+# @uppy/screen-capture
+
+<img src="https://uppy.io/images/logos/uppy-dog-head-arrow.svg" width="120" alt="Uppy logo: a superman puppy in a pink suit" align="right">
+
+<a href="https://www.npmjs.com/package/@uppy/screen-capture"><img src="https://img.shields.io/npm/v/@uppy/screen-capture.svg?style=flat-square"></a>
+<a href="https://travis-ci.org/transloadit/uppy"><img src="https://img.shields.io/travis/transloadit/uppy/master.svg?style=flat-square" alt="Build Status"></a>
+
+The Webcam plugin for Uppy lets you take photos and record videos with a built-in camera on desktop and mobile devices.
+
+Uppy is being developed by the folks at [Transloadit](https://transloadit.com), a versatile file encoding service.
+
+## Example
+
+```js
+const Uppy = require('@uppy/core')
+const ScreenCapture = require('@uppy/screen-capture')
+
+const uppy = Uppy()
+uppy.use(ScreenCapture)
+```
+
+## Installation
+
+```bash
+$ npm install @uppy/screen-capture --save
+```
+
+We recommend installing from npm and then using a module bundler such as [Webpack](https://webpack.js.org/), [Browserify](http://browserify.org/) or [Rollup.js](http://rollupjs.org/).
+
+Alternatively, you can also use this plugin in a pre-built bundle from Transloadit's CDN: Edgly. In that case `Uppy` will attach itself to the global `window.Uppy` object. See the [main Uppy documentation](https://uppy.io/docs/#Installation) for instructions.
+
+## Documentation
+
+Documentation for this plugin can be found on the [Uppy website](https://uppy.io/docs/webcam).
+
+## License
+
+[The MIT License](./LICENSE).

+ 36 - 0
packages/@uppy/screen-capture/package.json

@@ -0,0 +1,36 @@
+{
+  "name": "@uppy/screen-capture",
+  "description": "Uppy plugin that captures video from display or application.",
+  "version": "0.1.0",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "style": "dist/style.min.css",
+  "types": "types/index.d.ts",
+  "keywords": [
+    "file uploader",
+    "uppy",
+    "uppy-plugin",
+    "screen capture",
+    "video",
+    "record",
+    "mediarecorder"
+  ],
+  "homepage": "https://uppy.io",
+  "bugs": {
+    "url": "https://github.com/transloadit/uppy/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/transloadit/uppy.git"
+  },
+  "dependencies": {
+    "@uppy/utils": "file:../utils",
+    "preact": "8.2.9"
+  },
+  "peerDependencies": {
+    "@uppy/core": "^1.0.0"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}

+ 55 - 0
packages/@uppy/screen-capture/src/CaptureScreen.js

@@ -0,0 +1,55 @@
+const { h, Component } = require('preact')
+const RecordButton = require('./RecordButton')
+const SubmitButton = require('./SubmitButton')
+const StopWatch = require('./StopWatch')
+const StreamStatus = require('./StreamStatus')
+
+class RecorderScreen extends Component {
+  componentWillUnmount () {
+    this.props.onStop()
+  }
+
+  render () {
+    const { recording, stream: videoStream, recordedVideo } = this.props
+
+    const videoProps = {
+      playsinline: true
+    }
+
+    // show stream
+    if (recording || (!recordedVideo && !recording)) {
+      videoProps.muted = true
+      videoProps.autoplay = true
+      videoProps.srcObject = videoStream
+    }
+
+    // show preview
+    if (recordedVideo && !recording) {
+      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
+      }
+    }
+
+    return (
+      <div class="uppy uppy-ScreenCapture-container">
+        <div class="uppy-ScreenCapture-videoContainer">
+          <StreamStatus {...this.props} />
+          <video ref={videoElement => (this.videoElement = videoElement)} class="uppy-ScreenCapture-video" {...videoProps} />
+          <StopWatch {...this.props} />
+        </div>
+
+        <div class="uppy-ScreenCapture-buttonContainer">
+          <RecordButton {...this.props} />
+          <SubmitButton {...this.props} />
+        </div>
+      </div>
+    )
+  }
+}
+
+module.exports = RecorderScreen

+ 38 - 0
packages/@uppy/screen-capture/src/RecordButton.js

@@ -0,0 +1,38 @@
+const { h } = require('preact')
+
+/**
+ * Control screen capture recording. Will show record or stop button.
+ */
+module.exports = function RecordButton ({ recording, onStartRecording, onStopRecording, i18n }) {
+  if (recording) {
+    return (
+      <button
+        class="uppy-u-reset uppy-c-btn uppy-ScreenCapture-button uppy-ScreenCapture-button--video uppy-ScreenCapture-button--stop-rec"
+        type="button"
+        title={i18n('stopRecording')}
+        aria-label={i18n('stopRecording')}
+        onclick={onStopRecording}
+        data-uppy-super-focusable
+      >
+        <svg aria-hidden="true" focusable="false" class="UppyIcon" width="100" height="100" viewBox="0 0 100 100">
+          <rect x="15" y="15" width="70" height="70" />
+        </svg>
+      </button>
+    )
+  }
+
+  return (
+    <button
+      class="uppy-u-reset uppy-c-btn uppy-ScreenCapture-button uppy-ScreenCapture-button--video"
+      type="button"
+      title={i18n('startCapturing')}
+      aria-label={i18n('stopCapturing')}
+      onclick={onStartRecording}
+      data-uppy-super-focusable
+    >
+      <svg aria-hidden="true" focusable="false" class="UppyIcon" width="100" height="100" viewBox="0 0 100 100">
+        <circle cx="50" cy="50" r="40" />
+      </svg>
+    </button>
+  )
+}

+ 13 - 0
packages/@uppy/screen-capture/src/ScreenRecIcon.js

@@ -0,0 +1,13 @@
+const { h } = require('preact')
+
+module.exports = () => {
+  return (
+    <svg aria-hidden="true" focusable="false" width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
+      <g fill="none" fill-rule="evenodd">
+        <rect fill="#2C3E50" width="32" height="32" rx="16" />
+        <path d="M24.182 9H7.818C6.81 9 6 9.742 6 10.667v10c0 .916.81 1.666 1.818 1.666h4.546V24h7.272v-1.667h4.546c1 0 1.809-.75 1.809-1.666l.009-10C26 9.742 25.182 9 24.182 9zM24 21H8V11h16v10z" fill="#FFF" fill-rule="nonzero" />
+        <circle fill="#FFF" cx="16" cy="16" r="2" />
+      </g>
+    </svg>
+  )
+}

+ 105 - 0
packages/@uppy/screen-capture/src/StopWatch.js

@@ -0,0 +1,105 @@
+const { h, Component } = require('preact')
+
+class Stopwatch extends Component {
+  constructor (props) {
+    super(props)
+    this.state = { elapsedTime: 0 }
+
+    this.wrapperStyle = {
+      width: '100%',
+      height: '100%',
+      display: 'flex'
+    }
+
+    this.overlayStyle = {
+      position: 'absolute',
+      width: '100%',
+      height: '100%',
+      background: 'black',
+      opacity: 0.7
+    }
+
+    this.infoContainerStyle = {
+      marginLeft: 'auto',
+      marginRight: 'auto',
+      marginTop: 'auto',
+      marginBottom: 'auto',
+      zIndex: 1,
+      color: 'white'
+    }
+
+    this.infotextStyle = {
+      marginLeft: 'auto',
+      marginRight: 'auto',
+      marginBottom: '1rem',
+      fontSize: '1.5rem'
+    }
+
+    this.timeStyle = {
+      display: 'block',
+      fontWeight: 'bold',
+      marginLeft: 'auto',
+      marginRight: 'auto',
+      fontSize: '3rem',
+      fontFamily: 'Courier New'
+    }
+  }
+
+  startTimer () {
+    this.timerTick()
+    this.timerRunning = true
+  }
+
+  resetTimer () {
+    clearTimeout(this.timer)
+    this.setState({ elapsedTime: 0 })
+    this.timerRunning = false
+  }
+
+  timerTick () {
+    this.timer = setTimeout(() => {
+      this.setState({ elapsedTime: this.state.elapsedTime + 1 })
+      this.timerTick()
+    }, 1000)
+  }
+
+  fmtMSS (s) {
+    return (s - (s %= 60)) / 60 + (s > 9 ? ':' : ':0') + s
+  }
+
+  render () {
+    const { recording, i18n } = { ...this.props }
+
+    // second to minutes and seconds
+    const minAndSec = this.fmtMSS(this.state.elapsedTime)
+
+    if (recording && !this.timerRunning) {
+      this.startTimer()
+    }
+
+    if (!recording && this.timerRunning) {
+      this.resetTimer()
+    }
+
+    if (recording) {
+      return (
+        <div style={this.wrapperStyle}>
+          <div style={this.overlayStyle} />
+          <div style={this.infoContainerStyle}>
+            <div style={this.infotextStyle}>
+              {i18n('recording')}
+            </div>
+            <div style={this.timeStyle}>
+              {minAndSec}
+            </div>
+          </div>
+
+        </div>
+      )
+    } else {
+      return null
+    }
+  }
+}
+
+module.exports = Stopwatch

+ 13 - 0
packages/@uppy/screen-capture/src/StreamStatus.js

@@ -0,0 +1,13 @@
+const { h } = require('preact')
+
+module.exports = ({ streamActive, i18n }) => {
+  if (streamActive) {
+    return (
+      <div title={i18n('streamActive')} aria-label={i18n('streamActive')} class="uppy-ScreenCapture-icon--stream uppy-ScreenCapture-icon--streamActive"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" opacity=".1" fill="none" /><path d="M0 0h24v24H0z" fill="none" /><path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm18-7H5v1.63c3.96 1.28 7.09 4.41 8.37 8.37H19V7zM1 10v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" /></svg></div>
+    )
+  } else {
+    return (
+      <div title={i18n('streamPassive')} aria-label={i18n('streamPassive')} class="uppy-ScreenCapture-icon--stream"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" opacity=".1" fill="none" /><path d="M0 0h24v24H0z" fill="none" /><path d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11z" /></svg></div>
+    )
+  }
+}

+ 26 - 0
packages/@uppy/screen-capture/src/SubmitButton.js

@@ -0,0 +1,26 @@
+const { h } = require('preact')
+
+/**
+ * Submit recorded video to uppy. Enabled when file is available
+ */
+module.exports = function SubmitButton ({ recording, recordedVideo, onSubmit, i18n }) {
+  if (recordedVideo && !recording) {
+    return (
+      <button
+        class="uppy-u-reset uppy-c-btn uppy-ScreenCapture-button uppy-ScreenCapture-button--submit"
+        type="button"
+        title={i18n('submitRecordedFile')}
+        aria-label={i18n('submitRecordedFile')}
+        onclick={onSubmit}
+        data-uppy-super-focusable
+      >
+        <svg aria-hidden="true" focusable="false" class="UppyIcon" width="48" height="48" viewBox="0 0 48 48">
+          <path d="M0 0h48v48h-48z" fill="none" />
+          <path d="M38.71 20.07c-1.36-6.88-7.43-12.07-14.71-12.07-5.78 0-10.79 3.28-13.3 8.07-6.01.65-10.7 5.74-10.7 11.93 0 6.63 5.37 12 12 12h26c5.52 0 10-4.48 10-10 0-5.28-4.11-9.56-9.29-9.93zm-10.71 5.93v8h-8v-8h-6l10-10 10 10h-6z" />
+        </svg>
+      </button>
+    )
+  } else {
+    return null
+  }
+}

+ 415 - 0
packages/@uppy/screen-capture/src/index.js

@@ -0,0 +1,415 @@
+const { h } = require('preact')
+const { Plugin } = require('@uppy/core')
+const Translator = require('@uppy/utils/lib/Translator')
+const getFileTypeExtension = require('@uppy/utils/lib/getFileTypeExtension')
+const ScreenRecIcon = require('./ScreenRecIcon')
+const CaptureScreen = require('./CaptureScreen')
+
+// Adapted from: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
+function getMediaDevices () {
+  // check if screen capturing is supported
+  /* eslint-disable */
+  if (navigator &&
+    navigator.mediaDevices &&
+    navigator.mediaDevices.getDisplayMedia &&
+    window &&
+    window.MediaRecorder) {
+    return navigator.mediaDevices
+  }
+  /* eslint-enable */
+
+  return null
+}
+
+/**
+ * Screen capture
+ */
+module.exports = class ScreenCapture extends Plugin {
+  static VERSION = require('../package.json').version
+
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.mediaDevices = getMediaDevices()
+    this.protocol = location.protocol.match(/https/i) ? 'https' : 'http'
+    this.id = this.opts.id || 'ScreenCapture'
+    this.title = this.opts.title || 'Screencast'
+    this.type = 'acquirer'
+    this.icon = ScreenRecIcon
+
+    this.defaultLocale = {
+      strings: {
+        startCapturing: 'Begin screen capturing',
+        stopCapturing: 'Stop screen capturing',
+        submitRecordedFile: 'Submit captured video',
+        streamActive: 'Stream active',
+        streamPassive: 'Stream passive',
+        micDisabled: 'Microphone access denied by user',
+        timeRecording: 'Recording'
+      }
+    }
+
+    // set default options
+    // https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints
+    const defaultOptions = {
+      // https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#Properties_of_shared_screen_tracks
+      displayMediaConstraints: {
+        video: {
+          width: 1280,
+          height: 720,
+          frameRate: {
+            ideal: 3,
+            max: 5
+          }
+        },
+        cursor: 'motion',
+        displaySurface: 'monitor'
+      },
+      // https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints/audio
+      userMediaConstraints: {
+        audio: true
+      },
+      preferredVideoMimeType: 'video/webm'
+    }
+
+    // merge default options with the ones set by user
+    this.opts = { ...defaultOptions, ...opts }
+
+    // i18n
+    this.translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale])
+    this.i18n = this.translator.translate.bind(this.translator)
+    this.i18nArray = this.translator.translateArray.bind(this.translator)
+
+    // uppy plugin class related
+    this.install = this.install.bind(this)
+    this.setPluginState = this.setPluginState.bind(this)
+    this.render = this.render.bind(this)
+
+    // screen capturer related
+    this.start = this.start.bind(this)
+    this.stop = this.stop.bind(this)
+    this.startRecording = this.startRecording.bind(this)
+    this.stopRecording = this.stopRecording.bind(this)
+    this.submit = this.submit.bind(this)
+    this.streamInterrupted = this.streamInactivated.bind(this)
+
+    // initialize
+    this.captureActive = false
+    this.capturedMediaFile = null
+  }
+
+  install () {
+    // Return if browser doesn’t support getDisplayMedia and
+    if (!this.mediaDevices) {
+      this.uppy.log('Screen recorder access is not supported', 'error')
+      return null
+    }
+
+    this.setPluginState({
+      streamActive: false,
+      audioStreamActive: false
+    })
+
+    const target = this.opts.target
+    if (target) {
+      this.mount(target, this)
+    }
+  }
+
+  uninstall () {
+    if (this.videoStream) {
+      this.stop()
+    }
+
+    this.unmount()
+  }
+
+  start () {
+    if (!this.mediaDevices) {
+      return Promise.reject(new Error('Screen recorder access not supported'))
+    }
+
+    this.captureActive = true
+
+    this.selectAudioStreamSource()
+
+    this.selectVideoStreamSource()
+      .then(res => {
+        // something happened in start -> return
+        if (res === false) {
+          // Close the Dashboard panel if plugin is installed
+          // into Dashboard (could be other parent UI plugin)
+          if (this.parent && this.parent.hideAllPanels) {
+            this.parent.hideAllPanels()
+            this.captureActive = false
+          }
+        }
+      })
+  }
+
+  selectVideoStreamSource () {
+    // if active stream available, return it
+    if (this.videoStream) {
+      return new Promise(resolve => resolve(this.videoStream))
+    }
+
+    // ask user to select source to record and get mediastream from that
+    // eslint-disable-next-line compat/compat
+    return this.mediaDevices.getDisplayMedia(this.opts.displayMediaConstraints)
+      .then((videoStream) => {
+        this.videoStream = videoStream
+
+        // add event listener to stop recording if stream is interrupted
+        this.videoStream.addEventListener('inactive', (event) => {
+          this.streamInactivated()
+        })
+
+        this.setPluginState({
+          streamActive: true
+        })
+
+        return videoStream
+      })
+      .catch((err) => {
+        this.setPluginState({
+          screenRecError: err
+        })
+
+        this.userDenied = true
+
+        setTimeout(() => {
+          this.userDenied = false
+        }, 1000)
+
+        return false
+      })
+  }
+
+  selectAudioStreamSource () {
+    // if active stream available, return it
+    if (this.audioStream) {
+      return new Promise(resolve => resolve(this.audioStream))
+    }
+
+    // ask user to select source to record and get mediastream from that
+    // eslint-disable-next-line compat/compat
+    return this.mediaDevices.getUserMedia(this.opts.userMediaConstraints)
+      .then((audioStream) => {
+        this.audioStream = audioStream
+
+        this.setPluginState({
+          audioStreamActive: true
+        })
+
+        return audioStream
+      })
+      .catch((err) => {
+        if (err.name === 'NotAllowedError') {
+          this.uppy.info(this.i18n('micDisabled'), 'error', 5000)
+        }
+
+        return false
+      })
+  }
+
+  startRecording () {
+    const options = {}
+    this.capturedMediaFile = null
+    this.recordingChunks = []
+    const preferredVideoMimeType = this.opts.preferredVideoMimeType
+
+    this.selectVideoStreamSource()
+      .then((videoStream) => {
+        // Attempt to use the passed preferredVideoMimeType (if any) during recording.
+        // If the browser doesn't support it, we'll fall back to the browser default instead
+        if (preferredVideoMimeType && MediaRecorder.isTypeSupported(preferredVideoMimeType) && getFileTypeExtension(preferredVideoMimeType)) {
+          options.mimeType = preferredVideoMimeType
+        }
+
+        // prepare tracks
+        const tracks = [videoStream.getVideoTracks()[0]]
+
+        // merge audio if exits
+        if (this.audioStream) {
+          tracks.push(this.audioStream.getAudioTracks()[0])
+        }
+
+        // create new stream from video and audio
+        // eslint-disable-next-line compat/compat
+        this.outputStream = new MediaStream(tracks)
+
+        // initialize mediarecorder
+        this.recorder = new MediaRecorder(this.outputStream, options)
+
+        // push data to buffer when data available
+        this.recorder.addEventListener('dataavailable', (event) => {
+          this.recordingChunks.push(event.data)
+        })
+
+        // start recording
+        this.recorder.start()
+
+        // set plugin state to recording
+        this.setPluginState({
+          recording: true
+        })
+      })
+      .catch((err) => {
+        this.uppy.log(err, 'error')
+      })
+  }
+
+  streamInactivated () {
+    // get screen recorder state
+    const { recordedVideo, recording } = { ...this.getPluginState() }
+
+    if (!recordedVideo && !recording) {
+      // Close the Dashboard panel if plugin is installed
+      // into Dashboard (could be other parent UI plugin)
+      if (this.parent && this.parent.hideAllPanels) {
+        this.parent.hideAllPanels()
+      }
+    } else if (recording) {
+      // stop recorder if it is active
+      this.uppy.log('Capture stream inactive — stop recording')
+      this.stopRecording()
+    }
+
+    this.videoStream = null
+    this.audioStream = null
+
+    this.setPluginState({
+      streamActive: false, audioStreamActive: false
+    })
+  }
+
+  stopRecording () {
+    const stopped = new Promise((resolve, reject) => {
+      this.recorder.addEventListener('stop', () => {
+        resolve()
+      })
+
+      this.recorder.stop()
+    })
+
+    return stopped.then(() => {
+      // recording stopped
+      this.setPluginState({
+        recording: false
+      })
+      // get video file after recorder stopped
+      return this.getVideo()
+    }).then((file) => {
+      // store media file
+      this.capturedMediaFile = file
+
+      // create object url for capture result preview
+      this.setPluginState({
+        recordedVideo: URL.createObjectURL(file.data)
+      })
+    }).then(() => {
+      this.recordingChunks = null
+      this.recorder = null
+    }, (error) => {
+      this.recordingChunks = null
+      this.recorder = null
+      throw error
+    })
+  }
+
+  submit () {
+    try {
+      // add recorded file to uppy
+      if (this.capturedMediaFile) {
+        this.uppy.addFile(this.capturedMediaFile)
+      }
+    } catch (err) {
+      // Logging the error, exept restrictions, which is handled in Core
+      if (!err.isRestriction) {
+        this.uppy.log(err, 'error')
+      }
+    }
+  }
+
+  stop () {
+    // flush video stream
+    if (this.videoStream) {
+      this.videoStream.getVideoTracks().forEach((track) => {
+        track.stop()
+      })
+      this.videoStream.getAudioTracks().forEach((track) => {
+        track.stop()
+      })
+      this.videoStream = null
+    }
+
+    // flush audio stream
+    if (this.audioStream) {
+      this.audioStream.getAudioTracks().forEach((track) => {
+        track.stop()
+      })
+      this.audioStream.getVideoTracks().forEach((track) => {
+        track.stop()
+      })
+      this.audioStream = null
+    }
+
+    // flush output stream
+    if (this.outputStream) {
+      this.outputStream.getAudioTracks().forEach((track) => {
+        track.stop()
+      })
+      this.outputStream.getVideoTracks().forEach((track) => {
+        track.stop()
+      })
+      this.outputStream = null
+    }
+
+    // remove preview video
+    this.setPluginState({
+      recordedVideo: null
+    })
+
+    this.captureActive = false
+  }
+
+  getVideo () {
+    const mimeType = this.recordingChunks[0].type
+    const fileExtension = getFileTypeExtension(mimeType)
+
+    if (!fileExtension) {
+      return Promise.reject(new Error(`Could not retrieve recording: Unsupported media type "${mimeType}"`))
+    }
+
+    const name = `screencap-${Date.now()}.${fileExtension}`
+    const blob = new Blob(this.recordingChunks, { type: mimeType })
+    const file = {
+      source: this.id,
+      name: name,
+      data: new Blob([blob], { type: mimeType }),
+      type: mimeType
+    }
+
+    return Promise.resolve(file)
+  }
+
+  render (state) {
+    // get screen recorder state
+    const recorderState = this.getPluginState()
+
+    if (!recorderState.streamActive && !this.captureActive && !this.userDenied) {
+      this.start()
+    }
+
+    return (
+      <CaptureScreen
+        {...recorderState}
+        onStartRecording={this.startRecording}
+        onStopRecording={this.stopRecording}
+        onStop={this.stop}
+        onSubmit={this.submit}
+        i18n={this.i18n}
+        stream={this.videoStream}
+      />
+    )
+  }
+}

+ 143 - 0
packages/@uppy/screen-capture/src/style.scss

@@ -0,0 +1,143 @@
+@import '@uppy/core/src/_utils.scss';
+@import '@uppy/core/src/_variables.scss';
+
+.uppy-ScreenCapture-container {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-direction: column;
+}
+
+.uppy-ScreenCapture-videoContainer {
+  width: 100%;
+  flex: 1;
+  flex-grow: 1;
+  overflow: hidden;
+  background-color: $gray-800;
+  text-align: center;
+  position: relative;
+
+  .uppy-size--md & {
+    max-width: 100%;
+  }
+}
+
+.uppy-ScreenCapture-video {
+  max-width: 100%;
+  max-height: 100%;
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  margin: auto;
+  outline: 0;
+}
+
+.uppy-ScreenCapture-buttonContainer {
+  width: 100%;
+  height: 75px;
+  border-top: 1px solid $gray-200;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 20px;
+  background-color: $white;
+}
+
+.uppy-ScreenCapture-button {
+  width: 45px;
+  height: 45px;
+  border-radius: 50%;
+  color: $white;
+  cursor: pointer;
+  transition: all 0.3s;
+
+  .uppy-size--md & {
+    width: 60px;
+    height: 60px;
+  }
+
+  &:hover {
+    background-color: darken($red, 5%);
+  }
+
+  &:focus {
+    outline: none;
+    box-shadow: 0 0 0 0.2rem rgba($blue, 0.5);
+  }
+}
+
+.uppy-ScreenCapture-button svg {
+  width: 30px;
+  height: 30px;
+  max-width: 100%;
+  max-height: 100%;
+  display: inline-block;
+  vertical-align: text-top;
+  overflow: hidden;
+  fill: currentColor;
+}
+
+.uppy-ScreenCapture-button--submit {
+  background-color: $blue;
+  margin-left: 12px;
+
+  &:hover {
+    background-color: darken($blue, 5%);
+  }
+
+  &:disabled {
+    background-color: $gray-500;
+    cursor: default;
+  }
+
+  &:disabled:hover { 
+    background-color: $gray-200;
+  }
+}
+
+.uppy-ScreenCapture-title {
+  font-size: 22px;
+  line-height: 1.35;
+  font-weight: 400;
+  margin: 0;
+  margin-bottom: 5px;
+  padding: 0 15px;
+  max-width: 500px;
+  text-align: center;
+  color: $gray-800;
+}
+
+.uppy-ScreenCapture-icon--stream {
+  position: absolute;
+  right: 0;
+  top: 0;
+  margin: 1rem;
+  z-index:1;
+
+  svg {
+    fill: $gray-500;
+  }
+}
+
+.uppy-ScreenCapture-icon--streamActive svg {
+  animation: uppy-ScreenCapture-icon--blink 1s cubic-bezier(0.47, 0, 0.75, 0.72) infinite;
+}
+
+  @keyframes uppy-ScreenCapture-icon--blink {
+    0% { fill: $blue;} 
+    50% { fill: $gray-500; }
+    100% { fill: $blue; }
+  }
+
+.uppy-ScreenCapture-button--video {
+  color: $white;
+  background: $red;
+
+  &:hover {
+    background-color: darken($red, 10%);
+  }
+}

+ 30 - 0
packages/@uppy/screen-capture/types/index.d.ts

@@ -0,0 +1,30 @@
+import Uppy = require('@uppy/core');
+
+declare module ScreenCapture {
+
+  export interface ScreenCaptureOptions extends Uppy.PluginOptions {
+    displayMediaConstraints: {
+      audio: boolean,
+      video: { 
+        width: number, 
+        height: number,
+        frameRate: { 
+          ideal: number, 
+          max: number } 
+        }
+    },
+    preferredVideoMimeType: string
+  }
+}
+
+declare class ScreenCapture extends Uppy.Plugin {
+  constructor(uppy: Uppy.Uppy, opts: Partial<ScreenCapture.ScreenCaptureOptions>);
+}
+
+export = ScreenCapture;
+
+declare module '@uppy/core' {
+  export interface Uppy {
+    use(pluginClass: typeof ScreenCapture, opts: Partial<ScreenCapture.ScreenCaptureOptions>): Uppy.Uppy;
+  }
+}

+ 0 - 6
packages/@uppy/webcam/src/style.scss

@@ -20,13 +20,7 @@
   position: relative;
 }
 
-  .uppy-size--md .uppy-Webcam-videoContainer {
-    // height: initial;
-  }
-
 .uppy-Webcam-video {
-  // width: 100%;
-  // height: 100%;
   max-width: 100%;
   max-height: 100%;
   position: absolute;

+ 1 - 0
packages/uppy/index.js

@@ -28,6 +28,7 @@ exports.OneDrive = require('@uppy/onedrive')
 exports.Facebook = require('@uppy/facebook')
 exports.Url = require('@uppy/url')
 exports.Webcam = require('@uppy/webcam')
+exports.ScreenCapture = require('@uppy/screen-capture')
 
 // Uploaders
 exports.AwsS3 = require('@uppy/aws-s3')

+ 1 - 0
packages/uppy/index.mjs

@@ -26,6 +26,7 @@ export { default as OneDrive } from '@uppy/onedrive'
 export { default as Facebook } from '@uppy/facebook'
 export { default as Url } from '@uppy/url'
 export { default as Webcam } from '@uppy/webcam'
+export { default as ScreenCapture } from '@uppy/screen-capture'
 
 // Uploaders
 export { default as AwsS3 } from '@uppy/aws-s3'

+ 1 - 0
packages/uppy/package.json

@@ -56,6 +56,7 @@
     "@uppy/tus": "file:../@uppy/tus",
     "@uppy/url": "file:../@uppy/url",
     "@uppy/webcam": "file:../@uppy/webcam",
+    "@uppy/screen-capture": "file:../@uppy/screen-capture",
     "@uppy/xhr-upload": "file:../@uppy/xhr-upload"
   }
 }

+ 1 - 0
packages/uppy/src/style.scss

@@ -8,3 +8,4 @@
 @import '@uppy/status-bar/src/style.scss';
 @import '@uppy/url/src/style.scss';
 @import '@uppy/webcam/src/style.scss';
+@import '@uppy/screen-capture/src/style.scss'

+ 2 - 0
packages/uppy/types/index.d.ts

@@ -37,6 +37,8 @@ import Url = require('@uppy/url');
 export { Url };
 import Webcam = require('@uppy/webcam');
 export { Webcam };
+import ScreenCapture = require('@uppy/screen-capture');
+export { ScreenCapture };
 
 // Uploaders
 import AwsS3 = require('@uppy/aws-s3');

+ 1 - 0
packages/uppy/types/index.test-d.ts

@@ -20,6 +20,7 @@ import * as Uppy from '../';
     .use(Uppy.GoogleDrive, { target: Uppy.Dashboard, companionUrl: 'https://companion.uppy.io' })
     .use(Uppy.Instagram, { target: Uppy.Dashboard, companionUrl: 'https://companion.uppy.io' })
     .use(Uppy.Webcam, { target: Uppy.Dashboard })
+    .use(Uppy.ScreenCapture, { target: Uppy.Dashboard })
     .use(Uppy.Tus, { endpoint: 'https://master.tus.io/files/' })
     .on('complete', (result) => {
       console.log('Upload result:', result);

+ 1 - 0
website/inject.js

@@ -36,6 +36,7 @@ const packages = [
   '@uppy/drag-drop',
   '@uppy/file-input',
   '@uppy/webcam',
+  '@uppy/screen-capture',
   '@uppy/dropbox',
   '@uppy/google-drive',
   '@uppy/instagram',

+ 9 - 1
website/src/examples/dashboard/app.es6

@@ -9,6 +9,7 @@ const Facebook = require('@uppy/facebook')
 const OneDrive = require('@uppy/onedrive')
 const Url = require('@uppy/url')
 const Webcam = require('@uppy/webcam')
+const ScreenCapture = require('@uppy/screen-capture')
 const Tus = require('@uppy/tus')
 const localeList = require('../locale_list.json')
 
@@ -133,11 +134,18 @@ function uppySetOptions () {
 
   const WebcamInstance = window.uppy.getPlugin('Webcam')
   if (opts.Webcam && !WebcamInstance) {
-    window.uppy.use(Webcam, { target: Dashboard, companionUrl: COMPANION })
+    window.uppy.use(Webcam, { target: Dashboard })
   }
   if (!opts.Webcam && WebcamInstance) {
     window.uppy.removePlugin(WebcamInstance)
   }
+
+  const ScreenCapture = window.uppy.getPlugin('ScreenCapture')
+  if (opts.ScreenCapture && !ScreenCapture) {
+    window.uppy.use(ScreenCapture, { target: Dashboard })
+  } else {
+    window.uppy.removePlugin(ScreenCapture)
+  }
 }
 
 function whenLocaleAvailable (localeName, callback) {

+ 3 - 0
website/src/examples/dashboard/app.html

@@ -10,6 +10,7 @@
   </ul>
   <ul>
     <li><label for="opts-Webcam"><input type="checkbox" id="opts-Webcam" checked/> Webcam</label></li>
+    <li><label for="opts-ScreenCapture"><input type="checkbox" id="opts-ScreenCapture" checked/> Screen Capture</label></li>
     <li><label for="opts-GoogleDrive"><input type="checkbox" id="opts-GoogleDrive" checked/> Google Drive</label></li>
     <li><label for="opts-Dropbox"><input type="checkbox" id="opts-Dropbox" checked/> Dropbox</label></li>
     <li><label for="opts-Instagram"><input type="checkbox" id="opts-Instagram" checked/> Instagram</label></li>
@@ -37,6 +38,7 @@
   var optionInputs = {
     DashboardInline: document.querySelector('#opts-DashboardInline'),
     Webcam: document.querySelector('#opts-Webcam'),
+    ScreenCapture: document.querySelector('#opts-ScreenCapture'),
     GoogleDrive: document.querySelector('#opts-GoogleDrive'),
     Dropbox: document.querySelector('#opts-Dropbox'),
     Instagram: document.querySelector('#opts-Instagram'),
@@ -51,6 +53,7 @@
   var defaultOpts = {
     DashboardInline: true,
     Webcam: true,
+    ScreenCapture: true,
     GoogleDrive: true,
     Instagram: true,
     Dropbox: true,

+ 2 - 0
website/src/examples/dashboard/index.ejs

@@ -30,6 +30,7 @@ const Instagram = require('@uppy/instagram')
 const Facebook = require('@uppy/facebook')
 const OneDrive = require('@uppy/onedrive')
 const Webcam = require('@uppy/webcam')
+const ScreenCapture = require('@uppy/screen-capture')
 const Tus = require('@uppy/tus')
 
 const uppy = Uppy({
@@ -62,6 +63,7 @@ const uppy = Uppy({
 .use(Facebook, { target: Dashboard, companionUrl: 'https://companion.uppy.io' })
 .use(OneDrive, { target: Dashboard, companionUrl: 'https://companion.uppy.io' })
 .use(Webcam, { target: Dashboard })
+.use(ScreenCapture, { target: Dashboard })
 .use(Tus, { endpoint: 'https://master.tus.io/files/' })
 
 uppy.on('complete', result => {