diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 86854926..18c6bef7 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -42,6 +42,12 @@ "sources-scenario-user": "Kamera", "sources-display": "Bildschirm", "sources-user": "Kamera", + "sources-video-device": "Kamera", + "sources-video-aspect-ratio": "Seitenverhältnis", + "sources-video-aspect-ratio-auto": "auto", + "sources-video-quality": "Qualität", + "sources-video-quality-auto": "auto", + "sources-video-preferences-note": "<0>*Hinweis: Dies sind lediglich Präferenzen. Es ist nicht garantiert, dass alle Einstellungen von Ihrem Gerät unterstützt werden. Im Zweifelsfall 'auto' wählen.", "source-display-not-allowed-title": "Zugriff auf Bildschirm nicht möglich", "source-display-not-allowed-text": "Versuchen Sie, die Quelle neu auszuwählen. Falls das nicht funkioniert, laden Sie entweder diese Seite neu oder erlauben Sie dieser Seite manuell Zugriff auf Ihren Bildschirm (typischerweise über einen Button neben der Adressleiste Ihres Browsers)", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9ee5b52d..a0fddf4b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -46,6 +46,12 @@ "sources-video-none-available": "Your browser/device does not support capturing display or camera video :-(", "sources-video-reselect-source": "Reselect source(s)", "sources-video-no-cam-detected": "No camera detected", + "sources-video-device": "Device", + "sources-video-aspect-ratio": "Aspect ratio", + "sources-video-aspect-ratio-auto": "auto", + "sources-video-quality": "Quality", + "sources-video-quality-auto": "auto", + "sources-video-preferences-note": "<0>*Note: these are merely preferences and it cannot be guaranteed that all options are actually supported on your device. If in doubt, choose 'auto'.", "sources-audio-question": "Record Audio?", "sources-audio-microphone": "Microphone", diff --git a/src/studio-state.js b/src/studio-state.js index db7eb472..14175889 100644 --- a/src/studio-state.js +++ b/src/studio-state.js @@ -22,6 +22,8 @@ export const STATE_ERROR = 'error'; const initialState = () => ({ + mediaDevices: [], + audioAllowed: null, audioStream: null, audioUnexpectedEnd: false, @@ -57,6 +59,9 @@ const initialState = () => ({ const reducer = (state, action) => { switch (action.type) { + case 'UPDATE_MEDIA_DEVICES': + return { ...state, mediaDevices: action.payload }; + case 'CHOOSE_AUDIO': return { ...state, audioChoice: action.payload }; diff --git a/src/ui/studio/capturer.js b/src/ui/studio/capturer.js index 32c21283..23d8d269 100644 --- a/src/ui/studio/capturer.js +++ b/src/ui/studio/capturer.js @@ -1,3 +1,11 @@ +const mergeHeightConstraint = (maxHeight, videoConstraints, fallbackIdeal) => { + const maxField = maxHeight && { max: maxHeight }; + const ideal = videoConstraints?.height?.ideal || fallbackIdeal; + const idealField = ideal && (maxHeight ? { ideal: Math.min(ideal, maxHeight) } : { ideal }); + + return { height: { ...maxField, ...idealField } }; +}; + export async function startAudioCapture(dispatch, deviceId = null) { try { const stream = await navigator.mediaDevices.getUserMedia({ @@ -21,19 +29,18 @@ export async function startAudioCapture(dispatch, deviceId = null) { } } -export async function startDisplayCapture(dispatch, settings) { +export async function startDisplayCapture(dispatch, settings, videoConstraints = {}) { const maxFps = settings.display?.maxFps ? { frameRate: { max: settings.display.maxFps } } : {}; - const maxHeight = settings.display?.maxHeight - ? { height: { max: settings.display.maxHeight } } - : {}; + const height = mergeHeightConstraint(settings.display?.maxHeight, videoConstraints); const constraints = { video: { cursor: 'always', ...maxFps, - ...maxHeight, + ...videoConstraints, + ...height, }, audio: false, }; @@ -55,19 +62,18 @@ export async function startDisplayCapture(dispatch, settings) { } } -export async function startUserCapture(dispatch, settings) { +export async function startUserCapture(dispatch, settings, videoConstraints) { const maxFps = settings.camera?.maxFps ? { frameRate: { max: settings.camera.maxFps } } : {}; - const maxHeight = settings.camera?.maxHeight - ? { height: { ideal: Math.min(1080, settings.camera.maxHeight), max: settings.camera.maxHeight } } - : { height: { ideal: 1080 } }; + const height = mergeHeightConstraint(settings.camera?.maxHeight, videoConstraints, 1080); const constraints = { video: { facingMode: 'user', + ...videoConstraints, ...maxFps, - ...maxHeight, + ...height, }, audio: false, }; diff --git a/src/ui/studio/elements.js b/src/ui/studio/elements.js index e33e6b5b..426aac01 100644 --- a/src/ui/studio/elements.js +++ b/src/ui/studio/elements.js @@ -7,7 +7,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCaretLeft, faCaretRight } from '@fortawesome/free-solid-svg-icons'; import { Box, Button, Flex } from '@theme-ui/components'; import { useTranslation } from 'react-i18next'; -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import equal from 'fast-deep-equal'; // A div containing optional "back" and "next" buttons as well as the centered @@ -72,7 +72,7 @@ export const useVideoBoxResize = () => React.useContext(VideoBoxResizeContext); // `
` that maintains this exact aspect ratio. In the one child case, that // `
` also perfectly fits within the parent container. In the case of two // children, those children are laid out in such a way that the utilized screen -// space is maximized. +// space is maximized while both children have the same width or height. // // `children` has to be the length 1 or 2! The parameter `gap` specifies the // width of the empty space between the two children in the case that two @@ -81,26 +81,36 @@ export const useVideoBoxResize = () => React.useContext(VideoBoxResizeContext); // Each child in `children` needs to be an object with the following fields: // // - `body`: the rendered DOM. -// - `aspectRatio`: the desired aspect ratio for the child. -// - `extraHeight`: sometimes the rendered child has a fixed extra height (for -// example to render a title above or information below the video stream). -// This extra height is included in the calculation such that the child minus -// this extra height has the perfect `aspectRatio` as given above. -export function VideoBox({ gap = 0, children }) { +// - `dimensions`: a function returning `[width, height]` of the child (also +// defining the aspect ratio). We require the dimensions instead of only the +// aspect ratio to better detect changes in the video stream. +export function VideoBox({ gap = 0, minWidth = 180, children }) { const { ref, width = 1, height = 1 } = useResizeObserver(); // This is a dummy state to force a rerender. const [, setForceCounter] = useState(0); - const forceRender = () => { - setForceCounter(v => v + 1); - }; + const forceRender = () => setForceCounter(v => v + 1); + + // We try to remember the last valid dimension. Otherwise, changing video + // preferences for a non-16:9 strean leads to visual noise: the box always + // changes between its aspect ratio and the fallback 16:9 ratio. + const lastDimensions = useRef(children.map(() => [undefined, undefined])); + const updateLastDimensions = newDimensions => { + newDimensions.forEach(([w, h], i) => { + if (w && h) { + lastDimensions.current[i] = [w, h]; + } + }); + } // Setup the handler for when a video stream is resized. let dimensions = children.map(c => c.dimensions()); + updateLastDimensions(dimensions); const resizeVideoBox = () => { const newDimensions = children.map(c => c.dimensions()); if (!equal(newDimensions, dimensions)) { dimensions = newDimensions; + updateLastDimensions(dimensions); forceRender(); } } @@ -110,29 +120,31 @@ export function VideoBox({ gap = 0, children }) { switch (children.length) { case 1: { const child = children[0]; - const extraChildHeight = child.extraHeight || 0; - const aspectRatio = ar(child.dimensions()); - + const aspectRatio = ar(lastDimensions.current[0]); // Calculate size of child let childWidth; let childHeight; - const availableHeight = height - extraChildHeight; - if (width > availableHeight * aspectRatio) { + if (width > height * aspectRatio) { // Child height perfectly matches container, extra space left and right - childHeight = availableHeight + extraChildHeight; - childWidth = availableHeight * aspectRatio; + childHeight = height; + childWidth = height * aspectRatio; } else { // Child width perfectly matches container, extra space top and bottom childWidth = width; - childHeight = (width / aspectRatio) + extraChildHeight; + childHeight = (width / aspectRatio); } return (
-
+
{ child.body }
@@ -159,27 +171,25 @@ export function VideoBox({ gap = 0, children }) { // 1:0.75 respectively. We can now add those, resulting in 1:1.31. // Finally, we normalize with respect to height again: 0.76:1 - const aspectRatios = children.map(c => ar(c.dimensions())); + const aspectRatios = lastDimensions.current.map(d => ar(d)); // Videos side by side (row). const { rowWidths, rowHeights } = (() => { - const extraHeight = Math.max(children[0].extraHeight || 0, children[1].extraHeight || 0); - const availableHeight = height - extraHeight; const availableWidth = width - gap; const combinedAspectRatio = aspectRatios[0] + aspectRatios[1]; - if (availableWidth > availableHeight * combinedAspectRatio) { + if (availableWidth > height * combinedAspectRatio) { // Children height perfectly matches container, extra space left and // right. return { - rowHeights: Array(2).fill(availableHeight + extraHeight), - rowWidths: aspectRatios.map(ar => availableHeight * ar), + rowHeights: Array(2).fill(height), + rowWidths: aspectRatios.map(ar => height * ar), }; } else { // Children width perfectly matches container, extra space top and // bottom. const baseHeight = availableWidth / combinedAspectRatio; return { - rowHeights: children.map(c => baseHeight + (c.extraHeight || 0)), + rowHeights: children.map(c => baseHeight), rowWidths: aspectRatios.map(ar => baseHeight * ar), } } @@ -187,24 +197,22 @@ export function VideoBox({ gap = 0, children }) { // One video below the other (col/column). const { colWidths, colHeights } = (() => { - const extraHeight = gap + (children[0].extraHeight || 0) + (children[1].extraHeight || 0); - const availableHeight = height - extraHeight; const combinedAspectRatio = 1 / ((1 / aspectRatios[0]) + (1 / aspectRatios[1])); - if (width > availableHeight * combinedAspectRatio) { + if (width > height * combinedAspectRatio) { // Children height perfectly matches container, extra space left and // right. - const width = availableHeight * combinedAspectRatio; + const width = height * combinedAspectRatio; return { - colHeights: children.map((c, i) => (width / aspectRatios[i]) + (c.extraHeight || 0)), + colHeights: children.map((c, i) => (width / aspectRatios[i])), colWidths: Array(2).fill(width), }; } else { // Children width perfectly matches container, extra space top and // bottom. return { - colHeights: children.map((c, i) => (width / aspectRatios[i]) + (c.extraHeight || 0)), + colHeights: children.map((c, i) => (width / aspectRatios[i])), colWidths: Array(2).fill(width), } } @@ -239,10 +247,20 @@ export function VideoBox({ gap = 0, children }) { minHeight: 0, }} > -
+
{ children[0].body }
-
+
{ children[1].body }
diff --git a/src/ui/studio/save-creation/index.js b/src/ui/studio/save-creation/index.js index 8ec10cdf..7f1cb11d 100644 --- a/src/ui/studio/save-creation/index.js +++ b/src/ui/studio/save-creation/index.js @@ -27,7 +27,7 @@ import FormField from './form-field'; import RecordingPreview from './recording-preview'; -const LAST_PRESENTER_KEY = 'lastPresenter'; +const LAST_PRESENTER_KEY = 'ocStudioLastPresenter'; const Input = props => ; diff --git a/src/ui/studio/video-setup/index.js b/src/ui/studio/video-setup/index.js index 9f493d60..c16728c1 100644 --- a/src/ui/studio/video-setup/index.js +++ b/src/ui/studio/video-setup/index.js @@ -3,7 +3,7 @@ import { jsx } from 'theme-ui'; import { faChalkboard, faChalkboardTeacher, faUser } from '@fortawesome/free-solid-svg-icons'; -import { Container, Flex, Heading, Text } from '@theme-ui/components'; +import { Flex, Heading, Text } from '@theme-ui/components'; import { Styled } from 'theme-ui'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,51 +29,24 @@ import { } from '../capturer'; import { ActionButtons } from '../elements'; import { SourcePreview } from './preview'; +import { loadCameraPrefs, loadDisplayPrefs, prefsToConstraints } from './prefs'; -export default function VideoSetup(props) { +export default function VideoSetup({ nextStep, userHasWebcam }) { const { t } = useTranslation(); - const settings = useSettings(); const dispatch = useDispatch(); const state = useStudioState(); - const { - displayStream, - userStream, - displaySupported, - userSupported, - videoChoice: activeSource, - } = state; - + const { displayStream, userStream, videoChoice: activeSource } = state; const hasStreams = displayStream || userStream; - const anySupported = displaySupported || userSupported; - const bothSupported = displaySupported && userSupported; const setActiveSource = s => dispatch({ type: 'CHOOSE_VIDEO', payload: s }); - - - const clickUser = async () => { - setActiveSource(VIDEO_SOURCE_USER); - await startUserCapture(dispatch, settings); - }; - const clickDisplay = async () => { - setActiveSource(VIDEO_SOURCE_DISPLAY); - await startDisplayCapture(dispatch, settings); - }; - const clickBoth = async () => { - setActiveSource(VIDEO_SOURCE_BOTH); - await startUserCapture(dispatch, settings); - await startDisplayCapture(dispatch, settings); - }; - const reselectSource = () => { setActiveSource(VIDEO_SOURCE_NONE); - stopUserCapture(state.userStream, dispatch); - stopDisplayCapture(state.displayStream, dispatch); + stopUserCapture(userStream, dispatch); + stopDisplayCapture(displayStream, dispatch); }; - const userHasWebcam = props.userHasWebcam; - const nextDisabled = activeSource === VIDEO_SOURCE_NONE || activeSource === VIDEO_SOURCE_BOTH ? (!displayStream || !userStream) : !hasStreams; @@ -100,129 +73,85 @@ export default function VideoSetup(props) { ); + const userInput = { + isDesktop: false, + stream: userStream, + allowed: state.userAllowed, + unexpectedEnd: state.userUnexpectedEnd, + }; + const displayInput = { + isDesktop: true, + stream: displayStream, + allowed: state.displayAllowed, + unexpectedEnd: state.displayUnexpectedEnd, + }; + // The body depends on which source is currently selected. let hideActionButtons; let title; let body; switch (activeSource) { case VIDEO_SOURCE_NONE: + const userConstraints = prefsToConstraints(loadCameraPrefs()); + const displayConstraints = prefsToConstraints(loadDisplayPrefs()); title = t('sources-video-question'); hideActionButtons = true; - if (anySupported) { - body = - - :not(:last-of-type)': { - mb: [3, 0], - mr: [0, 3], - }, - }} - > - { displaySupported && } - { bothSupported && } - { userSupported && } - - - ; - } else { - body = {t('sources-video-none-available')}; - } + body = ; break; case VIDEO_SOURCE_USER: title = t('sources-video-user-selected'); - hideActionButtons = !state.userStream && state.userAllowed !== false; + hideActionButtons = !userStream && state.userAllowed !== false; body = ; break; case VIDEO_SOURCE_DISPLAY: title = t('sources-video-display-selected'); - hideActionButtons = !state.displayStream && state.displayAllowed !== false; + hideActionButtons = !displayStream && state.displayAllowed !== false; body = ; break; case VIDEO_SOURCE_BOTH: title = t('sources-video-display-and-user-selected'); - hideActionButtons = (!state.userStream && state.userAllowed !== false) - || (!state.displayStream && state.displayAllowed !== false); + hideActionButtons = (!userStream && state.userAllowed !== false) + || (!displayStream && state.displayAllowed !== false); body = ; break; default: - return

Something went very wrong

; + console.error('bug: active source has an unexpected value'); + return

Something went very wrong (internal error) :-(

; }; const hideReselectSource = hideActionButtons && !state.userUnexpectedEnd && !state.displayUnexpectedEnd; return ( - - - {title} + + { title } { body } @@ -230,20 +159,88 @@ export default function VideoSetup(props) { { activeSource !== VIDEO_SOURCE_NONE &&
} { activeSource !== VIDEO_SOURCE_NONE && props.nextStep(), - disabled: nextDisabled, - }} + next={hideActionButtons ? null : { onClick: () => nextStep(), disabled: nextDisabled }} prev={hideReselectSource ? null : { onClick: reselectSource, disabled: false, label: 'sources-video-reselect-source', }} - />} - + /> } +
); } +const SourceSelection = ({ setActiveSource, userConstraints, displayConstraints, userHasWebcam }) => { + const { t } = useTranslation(); + + const settings = useSettings(); + const dispatch = useDispatch(); + const state = useStudioState(); + const { displaySupported, userSupported } = state; + + const clickUser = async () => { + setActiveSource(VIDEO_SOURCE_USER); + await startUserCapture(dispatch, settings, userConstraints); + await queryMediaDevices(dispatch); + }; + const clickDisplay = async () => { + setActiveSource(VIDEO_SOURCE_DISPLAY); + await startDisplayCapture(dispatch, settings, displayConstraints); + }; + const clickBoth = async () => { + setActiveSource(VIDEO_SOURCE_BOTH); + await startUserCapture(dispatch, settings, userConstraints); + await Promise.all([ + queryMediaDevices(dispatch), + startDisplayCapture(dispatch, settings, displayConstraints), + ]); + }; + + + if (!displaySupported && !userSupported) { + return {t('sources-video-none-available')}; + } + + return + + :not(:last-of-type)': { + mb: [3, 0], + mr: [0, 3], + }, + }} + > + { displaySupported && } + { displaySupported && userSupported && } + { userSupported && } + + + ; +}; + const OptionButton = ({ icon, label, onClick, disabledText = false }) => { const disabled = disabledText !== false; @@ -280,3 +277,8 @@ const OptionButton = ({ icon, label, onClick, disabledText = false }) => { }; const Spacer = (rest) =>
; + +const queryMediaDevices = async (dispatch) => { + const devices = await navigator.mediaDevices.enumerateDevices(); + dispatch({ type: 'UPDATE_MEDIA_DEVICES', payload: devices }); +}; diff --git a/src/ui/studio/video-setup/prefs.js b/src/ui/studio/video-setup/prefs.js new file mode 100644 index 00000000..5c6fb0c8 --- /dev/null +++ b/src/ui/studio/video-setup/prefs.js @@ -0,0 +1,440 @@ +// Everything related to video stream preferences that the user can modify. + +//; -*- mode: rjsx;-*- +/** @jsx jsx */ +import { jsx } from 'theme-ui'; + +import { Fragment, useEffect, useState } from 'react'; +import { useTranslation, Trans } from 'react-i18next'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimes, faCog } from '@fortawesome/free-solid-svg-icons'; +import useResizeObserver from "use-resize-observer/polyfilled"; + +import { useSettings } from '../../../settings'; +import { deviceIdOf, dimensionsOf } from '../../../util.js'; +import { useDispatch, useStudioState } from '../../../studio-state'; +import { + startDisplayCapture, + startUserCapture, + stopDisplayCapture, + stopUserCapture +} from '../capturer'; + + +// Creates a valid constraints object from the given preferences. The mapping +// is as follows: +// +// - deviceId: falsy values are ignored, any other value is passed on, either as +// `ideal` (if `exactDevice` is `false`) or `exact` (if `exactDevice` is +// `true`). +// - aspectRatio: values in `ASPECT_RATIOS` are passed as `ideal`, everything +// else is ignored. +// - quality: valid quality labels are passed on as `ideal` height, invalid ones +// are ignored. +export const prefsToConstraints = (prefs, exactDevice = false) => { + const deviceConstraint = prefs.deviceId + && { deviceId: { [exactDevice ? 'exact' : 'ideal']: prefs.deviceId }}; + + const aspectRatio = parseAspectRatio(prefs.aspectRatio); + const aspectRatioConstraint = aspectRatio && { aspectRatio: { ideal: aspectRatio }}; + + const height = parseQuality(prefs.quality); + const heightConstraint = height && { height: { ideal: height }}; + + return { + ...deviceConstraint, + ...aspectRatioConstraint, + ...heightConstraint, + }; +}; + +// All aspect ratios the user can choose from. +const ASPECT_RATIOS = ['4:3', '16:9']; + +// All quality options given to the user respecting the `maxHeight` from the +// settings. +const qualityOptions = maxHeight => { + const defaults = [480, 720, 1080, 1440, 2160]; + let out = defaults.filter(q => !maxHeight || q <= maxHeight); + if (maxHeight && (out.length === 0 || out[out.length - 1] !== maxHeight)) { + out.push(maxHeight); + } + + return out.map(n => `${n}p`); +} + +// Converts the given aspect ratio label (one of the elements in +// `ASPECT_RATIOS`) into the numerical ratio, e.g. 4/3 = 1.333. If the argument +// is not a valid label, `null` is returned. +const parseAspectRatio = label => { + const mapping = { + '4:3': 4 / 3, + '16:9': 16 / 9, + }; + + return mapping[label] || null; +} + +// Converts the given quality label to the actual height as number. If the +// argument is not a valid quality label (e.g. '720p'), `null` is returned. +const parseQuality = label => { + if (!/^[0-9]+p$/.test(label)) { + return null; + } + + return parseInt(label); +}; + +// Local storage keys +const LAST_VIDEO_DEVICE_KEY = 'ocStudioLastVideoDevice'; +const CAMERA_ASPECT_RATIO_KEY = 'ocStudioCameraAspectRatio'; +const CAMERA_QUALITY_KEY = 'ocStudioCameraQuality'; +const DISPLAY_QUALITY_KEY = 'ocStudioDisplayQuality'; + +// Loads the initial camera preferences from local storage. +export const loadCameraPrefs = () => ({ + deviceId: window.localStorage.getItem(LAST_VIDEO_DEVICE_KEY), + aspectRatio: window.localStorage.getItem(CAMERA_ASPECT_RATIO_KEY) || 'auto', + quality: window.localStorage.getItem(CAMERA_QUALITY_KEY) || 'auto', +}); + +// Loads the initial display preferences from local storage. +export const loadDisplayPrefs = () => ({ + quality: window.localStorage.getItem(DISPLAY_QUALITY_KEY) || 'auto', +}); + + +export const StreamSettings = ({ isDesktop, stream }) => { + const dispatch = useDispatch(); + const settings = useSettings(); + const [expandedHeight, setExpandedHeight] = useState(0); + const { ref } = useResizeObserver({ + // We don't use the height passed to the callback as we want the outer + // height. We also add a magic "4" here. 2 pixels are for the border of the + // outer div. The other two are "wiggle room". If we always set the height + // to fit exactly, this easily leads to unnessecary scrollbars appearing. + // This in turn might lead to rewrapping and then a change in height, in + // the worst case ending up in an infinite loop. + onResize: () => setExpandedHeight(ref.current?.offsetHeight + 4), + }); + + // The current preferences and the callback to update them. + const prefs = isDesktop ? loadDisplayPrefs() : loadCameraPrefs(); + const updatePrefs = newPrefs => { + // Merge and update preferences. + const merged = { ...prefs, ...newPrefs }; + const constraints = prefsToConstraints(merged, true); + + // Update preferences in local storage and re-request stream. The latter + // will cause the rerender. + if (isDesktop) { + window.localStorage.setItem(DISPLAY_QUALITY_KEY, merged.quality); + + stopDisplayCapture(stream, dispatch); + startDisplayCapture(dispatch, settings, constraints); + } else { + window.localStorage.setItem(LAST_VIDEO_DEVICE_KEY, merged.deviceId); + window.localStorage.setItem(CAMERA_ASPECT_RATIO_KEY, merged.aspectRatio); + window.localStorage.setItem(CAMERA_QUALITY_KEY, merged.quality); + + stopUserCapture(stream, dispatch); + startUserCapture(dispatch, settings, constraints); + } + }; + + // Store the camera device ID in local storage. We also do this here, as we + // also want to remember the device the user initially selected in the browser + // popup. + useEffect(() => { + const cameraDeviceId = deviceIdOf(stream); + if (!isDesktop && cameraDeviceId) { + window.localStorage.setItem(LAST_VIDEO_DEVICE_KEY, cameraDeviceId); + } + }); + + // State about expanding and hiding the settings. + const [isExpanded, setIsExpanded] = useState(false); + + return +
+ + + +
+
+
+
setIsExpanded(old => !old)} + sx={{ + display: 'inline-block', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + color: 'white', + p: '6px', + m: 2, + fontSize: '30px', + lineHeight: '1em', + borderRadius: '10px', + cursor: 'pointer', + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + }, + '&:hover > svg': { + transform: isExpanded ? 'none' : 'rotate(45deg)', + }, + }} + > + +
+
+
+
`1px solid ${theme.colors.gray[0]}`, + height: '100%', + overflow: 'auto', + }}> +
+
+ { !isDesktop && } + +
+ +
`1px solid ${theme.colors.gray[2]}`, + }}> + + Note: Explanation. + +
+
+
+
+
+
; +}; + +const StreamInfo = ({ stream }) => { + const s = stream?.getVideoTracks()?.[0]?.getSettings(); + const sizeInfo = (s && s.width && s.height) ? `${s.width}×${s.height}` : ''; + const fpsInfo = (s && s.frameRate) ? `${s.frameRate} fps` : ''; + + return s ? [sizeInfo, fpsInfo].join(', ') : '...'; +}; + +const PrefKey = ({ children }) => ( +
+ { children } +
+); +const PrefValue = ({ children }) => ( +
+ { children } +
+); + +const UniveralSettings = ({ isDesktop, updatePrefs, prefs, stream, settings }) => { + const { t } = useTranslation(); + + const changeQuality = quality => updatePrefs({ quality }); + const maxHeight = isDesktop ? settings.display?.maxHeight : settings.camera?.maxHeight; + const qualities = qualityOptions(maxHeight); + const kind = isDesktop ? 'desktop' : 'user'; + + const [, currentHeight] = dimensionsOf(stream); + let fitState; + if (currentHeight && qualities.includes(prefs.quality)) { + const expectedHeight = parseInt(prefs.quality); + fitState = expectedHeight === currentHeight ? 'ok': 'warn'; + } + + return + {t('sources-video-quality')}*: + + prefs.quality !== q)} + /> + { + qualities.map(q => + + + ) + } + + ; +}; + +const UserSettings = ({ updatePrefs, prefs }) => { + const { t } = useTranslation(); + const state = useStudioState(); + + const currentDeviceId = deviceIdOf(state.userStream); + let devices = []; + for (const d of state.mediaDevices) { + // Only intersted in video inputs + if (d.kind !== 'videoinput') { + continue; + } + + // If we already have a device with that device ID, we ignore it. + if (devices.some(od => od.deviceId === d.deviceId)) { + continue; + } + + devices.push(d); + } + + const [width, height] = dimensionsOf(state.userStream); + let arState; + if (width && height && ASPECT_RATIOS.includes(prefs.aspectRatio)) { + const currentAr = width / height; + const expectedAr = parseAspectRatio(prefs.aspectRatio); + + // We have some range we accept as "good". You never know with these + // floats... + arState = (expectedAr * 0.97 < currentAr && currentAr < expectedAr / 0.97) + ? 'ok' + : 'warn'; + } + + const changeDevice = id => updatePrefs({ deviceId: id }); + const changeAspectRatio = ratio => updatePrefs({ aspectRatio: ratio }); + + return + {t('sources-video-device')}: + + + + + {t('sources-video-aspect-ratio')}*: + + prefs.aspectRatio !== x)} + /> + { + ASPECT_RATIOS.map(ar => + + + ) + } + + ; +}; + +// A styled radio input which looks like a button. +const RadioButton = ({ id, value, checked, name, onChange, label, state }) => { + const stateColorMap = { + 'warn': '#ffe300', + 'ok': '#51d18f', + }; + + return + onChange(e.target.value)} + {...{ id, value, checked, name }} + sx={{ + display: 'none', + '&+label': { + border: theme => `2px solid ${theme.colors.gray[0]}`, + p: '1px 4px', + borderRadius: '6px', + mx: 1, + }, + '&:checked+label': { + bg: 'gray.0', + color: state ? stateColorMap[state] : 'white', + fontWeight: 'bold', + }, + }} + /> + + ; +}; diff --git a/src/ui/studio/video-setup/preview.js b/src/ui/studio/video-setup/preview.js index fdeeca18..24b24a83 100644 --- a/src/ui/studio/video-setup/preview.js +++ b/src/ui/studio/video-setup/preview.js @@ -3,37 +3,35 @@ import { jsx } from 'theme-ui'; import { Fragment, useEffect, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button, Card, Text, Spinner } from '@theme-ui/components'; +import { Card, Spinner } from '@theme-ui/components'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faExclamationTriangle, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import { VideoBox, useVideoBoxResize } from '../elements.js'; import { dimensionsOf } from '../../../util.js'; +import { StreamSettings } from './prefs'; -const SUBBOX_HEIGHT = 40; -export function SourcePreview({ reselectSource, warnings, inputs }) { +// Shows the preview for one or two input streams. The previews also show +// preferences allowing the user to change the webcam and the like. +export const SourcePreview = ({ warnings, inputs }) => { let children; switch (inputs.length) { case 1: children = [{ - body: , + body: , dimensions: () => dimensionsOf(inputs[0].stream), - extraHeight: SUBBOX_HEIGHT, }]; break; case 2: children = [ { - body: , + body: , dimensions: () => dimensionsOf(inputs[0].stream), - extraHeight: SUBBOX_HEIGHT, }, { - body: , + body: , dimensions: () => dimensionsOf(inputs[1].stream), - extraHeight: SUBBOX_HEIGHT, }, ]; break; @@ -41,31 +39,26 @@ export function SourcePreview({ reselectSource, warnings, inputs }) { return

Something went very wrong

; } + // Below this value, the video preference menu looks awful. + const minWidth = 300; + return ( { warnings } - { children } + { children } ); } -function StreamPreview({ input, text }) { - const stream = input.stream; - const track = stream?.getVideoTracks()?.[0]; - const { width, height } = track?.getSettings() ?? {}; - - return ( - - - - {text} - {track && `: ${width}×${height}`} - - - ); -} +const StreamPreview = ({ input, text }) => ( + + + + +); -export const PreviewVideo = ({ allowed, stream, unexpectedEnd, ...props }) => { +const PreviewVideo = ({ input }) => { + const { allowed, stream, unexpectedEnd } = input; const resizeVideoBox = useVideoBoxResize(); const videoRef = useRef(); @@ -123,14 +116,3 @@ export const PreviewVideo = ({ allowed, stream, unexpectedEnd, ...props }) => { /> ); }; - -export const UnshareButton = ({ handleClick }) => { - const { t } = useTranslation(); - - return ( - - ); -}; diff --git a/src/util.js b/src/util.js index d9d43126..ffff3c6f 100644 --- a/src/util.js +++ b/src/util.js @@ -42,6 +42,9 @@ export const dimensionsOf = stream => { return [width, height]; }; +// Returns the devide ID of the video track of the given stream. +export const deviceIdOf = stream => stream?.getVideoTracks()?.[0]?.getSettings()?.deviceId; + // Converts the MIME type into a file extension. export const mimeToExt = mime => { if (mime) { @@ -95,7 +98,7 @@ export const userHasWebcam = async () => { return false; } - const devices = await navigator.mediaDevices.enumerateDevices() + const devices = await navigator.mediaDevices.enumerateDevices(); return devices.some(d => d.kind === 'videoinput'); }