697 lines
22 KiB
JavaScript
697 lines
22 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback,
|
|
useContext } from "react";
|
|
import Moveable from "react-moveable";
|
|
|
|
import "./MediaControl.css";
|
|
|
|
import VolumeOff from '@mui/icons-material/VolumeOff';
|
|
import VolumeUp from '@mui/icons-material/VolumeUp';
|
|
import MicOff from '@mui/icons-material/MicOff';
|
|
import Mic from '@mui/icons-material/Mic';
|
|
import VideocamOff from '@mui/icons-material/VideocamOff';
|
|
import Videocam from '@mui/icons-material/Videocam';
|
|
|
|
import { GlobalContext } from "./GlobalContext.js";
|
|
const debug = true;
|
|
|
|
/* Proxy object so we can pass in srcObject to <audio> */
|
|
const Video = ({ srcObject, local, ...props }) => {
|
|
const refVideo = useRef(null);
|
|
useEffect(() => {
|
|
if (!refVideo.current) {
|
|
return;
|
|
}
|
|
const ref = refVideo.current;
|
|
if (debug) console.log('media-control - video <video> bind');
|
|
ref.srcObject = srcObject;
|
|
if (local) {
|
|
ref.muted = true;
|
|
}
|
|
return () => {
|
|
if (debug) console.log('media-control - <video> unbind');
|
|
if (ref) {
|
|
ref.srcObject = undefined;
|
|
}
|
|
};
|
|
}, [srcObject, local]);
|
|
return <video ref={refVideo} {...props} />;
|
|
}
|
|
|
|
const MediaAgent = ({setPeers}) => {
|
|
const { name, ws } = useContext(GlobalContext);
|
|
const [ peers ] = useState({});
|
|
const [stream, setStream] = useState(undefined);
|
|
|
|
const onTrack = useCallback((event) => {
|
|
const connection = event.target;
|
|
console.log("media-agent - ontrack", event);
|
|
for (let peer in peers) {
|
|
if (peers[peer].connection === connection) {
|
|
console.log(`media-agent - ontrack - remote ${peer} stream assigned.`);
|
|
Object.assign(peers[peer].attributes, {
|
|
srcObject: event.streams[0]
|
|
});
|
|
/* Trigger update of MediaControl now that a stream is available */
|
|
setPeers(Object.assign({}, peers));
|
|
}
|
|
}
|
|
}, [peers, setPeers]);
|
|
const refOnTrack = useRef(onTrack);
|
|
|
|
const sendMessage = useCallback((data) => {
|
|
ws.send(JSON.stringify(data));
|
|
}, [ws]);
|
|
|
|
const onWsMessage = useCallback((event) => {
|
|
const addPeer = (config) => {
|
|
console.log('media-agent - Signaling server said to add peer:', config);
|
|
|
|
if (!stream) {
|
|
console.log(`media-agent - No local media stream`);
|
|
return;
|
|
}
|
|
|
|
const peer_id = config.peer_id;
|
|
if (peer_id in peers) {
|
|
if (!peers[peer_id].dead) {
|
|
/* This is normal when peers are added by other connecting
|
|
* peers through the signaling server */
|
|
console.log(`media-agent - addPeer - ${peer_id} already in peers`);
|
|
return;
|
|
}
|
|
}
|
|
/* Even if reviving, allocate a new Object so <MediaControl> will
|
|
* have its peer state change and trigger an update from
|
|
* <PlayerList> */
|
|
const peer = {
|
|
name: peer_id,
|
|
hasAudio: config.hasAudio,
|
|
hasVideo: config.hasVideo,
|
|
attributes: {},
|
|
};
|
|
|
|
if (peer_id in peers) {
|
|
peer.muted = peers[peer_id].muted;
|
|
peer.videoOn = peers[peer_id].videoOn;
|
|
console.log(`media-agent - addPeer - reviving dead peer ${peer_id}`, peer);
|
|
} else {
|
|
peer.muted = false;
|
|
peer.videoOn = true;
|
|
}
|
|
peers[peer_id] = peer;
|
|
|
|
console.log(`media-agent - addPeer - remote`, peers);
|
|
setPeers(Object.assign({}, peers));
|
|
|
|
const connection = new RTCPeerConnection({
|
|
configuration: {
|
|
offerToReceiveAudio: true,
|
|
offerToReceiveVideo: true
|
|
},
|
|
iceServers: [{
|
|
urls: "turns:ketrenos.com:5349",
|
|
username: "ketra",
|
|
credential: "ketran"
|
|
},
|
|
/*
|
|
{
|
|
urls: "turn:numb.viagenie.ca",
|
|
username: "james_viagenie@ketrenos.com",
|
|
credential: "1!viagenie"
|
|
}
|
|
*/
|
|
]
|
|
});
|
|
peer.connection = connection;
|
|
|
|
connection.addEventListener('connectionstatechange', (event) => {
|
|
console.log(`media-agent - connectionstatechange - `,
|
|
connection.connectionState, event);
|
|
});
|
|
|
|
connection.addEventListener('negotiationneeded', (event) => {
|
|
console.log(`media-agent - negotiationneeded - `,
|
|
connection.connectionState, event);
|
|
});
|
|
|
|
connection.addEventListener('icecandidateerror', (event) => {
|
|
if (event.errorCode === 701) {
|
|
if (connection.icegatheringstate === 'gathering') {
|
|
console.log(`media-agent - Unable to reach host: ${event.url}`);
|
|
} else {
|
|
console.error(`media-agent - icecandidateerror - `, event.errorCode, event.hostcandidate, event.url, event.errorText);
|
|
}
|
|
}
|
|
});
|
|
|
|
connection.onicecandidate = (event) => {
|
|
if (!event.candidate) {
|
|
console.log(`media-agent - icecanditate - gathering is complete: ${connection.connectionState}`);
|
|
return;
|
|
}
|
|
/* If a srflx candidate was found, notify that the STUN server works! */
|
|
if (event.candidate.type === "srflx"){
|
|
console.log("media-agent - The STUN server is reachable!");
|
|
console.log(`media-agent - Your Public IP Address is: ${event.candidate.address}`);
|
|
}
|
|
|
|
/* If a relay candidate was found, notify that the TURN server works! */
|
|
if (event.candidate.type === "relay"){
|
|
console.log("media-agent - The TURN server is reachable !");
|
|
}
|
|
|
|
console.log(`media-agent - onicecandidate - `, event.candidate);
|
|
|
|
sendMessage({
|
|
type: 'relayICECandidate',
|
|
config: {
|
|
peer_id,
|
|
candidate: event.candidate
|
|
}
|
|
});
|
|
};
|
|
|
|
connection.ontrack = e => refOnTrack.current(e);
|
|
|
|
/* Add our local stream */
|
|
connection.addStream(stream.media);
|
|
|
|
/* Only one side of the peer connection should create the
|
|
* offer, the signaling server picks one to be the offerer.
|
|
* The other user will get a 'sessionDescription' event and will
|
|
* create an offer, then send back an answer 'sessionDescription'
|
|
* to us
|
|
*/
|
|
if (config.should_create_offer) {
|
|
if (debug) console.log(`media-agent - Creating RTC offer to ` +
|
|
`${peer_id}`);
|
|
return connection.createOffer()
|
|
.then((local_description) => {
|
|
if (debug) console.log(`media-agent - Local offer ` +
|
|
`description is: `, local_description);
|
|
return connection.setLocalDescription(local_description)
|
|
.then(() => {
|
|
sendMessage({
|
|
type: 'relaySessionDescription',
|
|
config: {
|
|
peer_id,
|
|
'session_description': local_description
|
|
}
|
|
});
|
|
if (debug) console.log(`media-agent - Offer ` +
|
|
`setLocalDescription succeeded`);
|
|
})
|
|
.catch((error) => {
|
|
console.error(`media-agent - Offer setLocalDescription failed!`);
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
console.log(`media-agente - Error sending offer: `, error);
|
|
});
|
|
}
|
|
}
|
|
|
|
const sessionDescription = ({ peer_id, session_description }) => {
|
|
const peer = peers[peer_id];
|
|
if (!peer) {
|
|
console.error(`media-agent - sessionDescription - ` +
|
|
`No peer for ${peer_id}`);
|
|
return;
|
|
}
|
|
const { connection } = peer;
|
|
const desc = new RTCSessionDescription(session_description);
|
|
return connection.setRemoteDescription(desc, () => {
|
|
if (debug) console.log(`media-agent - sessionDescription - ` +
|
|
`setRemoteDescription succeeded`);
|
|
if (session_description.type === "offer") {
|
|
if (debug) console.log(`media-agent - sessionDescription - ` +
|
|
`Creating answer`);
|
|
connection.createAnswer((local_description) => {
|
|
if (debug) console.log(`media-agent - sessionDescription - ` +
|
|
`Answer description is: `, local_description);
|
|
connection.setLocalDescription(local_description, () => {
|
|
sendMessage({
|
|
type: 'relaySessionDescription',
|
|
config: {
|
|
peer_id,
|
|
session_description: local_description
|
|
}
|
|
});
|
|
if (debug) console.log(`media-agent - sessionDescription ` +
|
|
`- Answer setLocalDescription succeeded`);
|
|
}, () => {
|
|
console.error(`media-agent - sessionDescription - ` +
|
|
`Answer setLocalDescription failed!`);
|
|
});
|
|
}, (error) => {
|
|
console.error(error);
|
|
});
|
|
}
|
|
}, (error) => {
|
|
console.log(`media-agent - sessionDescription - ` +
|
|
`setRemoteDescription error: `, error);
|
|
});
|
|
};
|
|
|
|
const removePeer = ({peer_id}) => {
|
|
console.log(`media-agent - removePeer - Signaling server said to ` +
|
|
`remove peer ${peer_id}`);
|
|
if (peer_id in peers) {
|
|
if (peers[peer_id].connection) {
|
|
peers[peer_id].connection.close();
|
|
peers[peer_id].connection = undefined;
|
|
}
|
|
}
|
|
|
|
/* To maintain mute/videoOn states, we don't remove the peer but
|
|
* instead mark it as dead */
|
|
peers[peer_id].dead = true;
|
|
if (debug) console.log(`media-agent - removePeer`, peers);
|
|
setPeers(Object.assign({}, peers));
|
|
};
|
|
|
|
const iceCandidate = ({ peer_id, candidate }) => {
|
|
/**
|
|
* The offerer will send a number of ICE Candidate blobs to the
|
|
* answerer so they can begin trying to find the best path to one
|
|
* another on the net.
|
|
*/
|
|
const peer = peers[peer_id];
|
|
if (!peer) {
|
|
console.error(`media-agent - iceCandidate - No peer for ` +
|
|
`${peer_id}`, peers);
|
|
return;
|
|
}
|
|
peer.connection.addIceCandidate(new RTCIceCandidate(candidate))
|
|
.then(() => {
|
|
if (debug) console.log(`media-agent - iceCandidate - ` +
|
|
`Successfully added Ice Candidate for ${peer_id}`);
|
|
})
|
|
.catch((error) => {
|
|
console.error(error, peer, candidate);
|
|
});
|
|
};
|
|
|
|
const data = JSON.parse(event.data);
|
|
if (data.type in [ 'addPeer', 'removePeer',
|
|
'iceCandidate', 'sessionDescription' ]) {
|
|
console.log(`media-agent - message - ${data.type}`, peers);
|
|
}
|
|
switch (data.type) {
|
|
case 'addPeer': addPeer(data.data); break;
|
|
case 'removePeer': removePeer(data.data); break;
|
|
case 'iceCandidate': iceCandidate(data.data); break;
|
|
case 'sessionDescription': sessionDescription(data.data); break;
|
|
default: break;
|
|
}
|
|
}, [ peers, setPeers, stream, refOnTrack, sendMessage ]);
|
|
const refWsMessage = useRef(onWsMessage);
|
|
|
|
const onWsClose = (event) => {
|
|
console.log(`media-agent - ${name} Disconnected from signaling server`);
|
|
/* Tear down all of our peer connections and remove all the
|
|
* media divs when we disconnect */
|
|
for (let peer_id in peers) {
|
|
if (peers[peer_id].local) {
|
|
continue;
|
|
}
|
|
peers[peer_id].connection.close();
|
|
peers[peer_id].connection = undefined;
|
|
}
|
|
|
|
for (let id in peers) {
|
|
peers[id].dead = true;
|
|
peers[id].connection = undefined;
|
|
}
|
|
|
|
if (debug) console.log(`media-agent - close`, peers);
|
|
setPeers(Object.assign({}, peers));
|
|
}
|
|
const refWsClose = useRef(onWsClose);
|
|
|
|
useEffect(() => {
|
|
refWsMessage.current = onWsMessage;
|
|
refWsClose.current = onWsClose;
|
|
refOnTrack.current = onTrack;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!ws) {
|
|
return;
|
|
}
|
|
console.log(`media-control - Binding to WebSocket`);
|
|
const cbMessage = e => refWsMessage.current(e);
|
|
ws.addEventListener('message', cbMessage);
|
|
const cbClose = e => refWsClose.current(e);
|
|
ws.addEventListener('close', cbClose);
|
|
return () => {
|
|
ws.removeEventListener('message', cbMessage);
|
|
ws.removeEventListener('close', cbClose);
|
|
}
|
|
}, [ws, refWsMessage, refWsClose ]);
|
|
|
|
useEffect(() => {
|
|
console.log(`media-control - WebSocket or Stream changed`);
|
|
|
|
const join = () => {
|
|
sendMessage({
|
|
type: 'join',
|
|
config: {
|
|
hasAudio: stream.audio,
|
|
hasVideo: stream.video
|
|
}
|
|
});
|
|
}
|
|
|
|
if (ws && stream) {
|
|
console.log(`media-conterol - issuing join request`);
|
|
for (let peer in peers) {
|
|
if (peers[peer].local && peers[peer].dead) {
|
|
/* Allocate a new Object so <MediaControl> will trigger */
|
|
peers[peer] = Object.assign({}, peers[peer]);
|
|
delete peers[peer].dead;
|
|
setPeers(Object.assign({}, peers));
|
|
}
|
|
}
|
|
join();
|
|
}
|
|
}, [ws, stream, peers, setPeers, sendMessage]);
|
|
|
|
useEffect(() => {
|
|
if (!name) {
|
|
return;
|
|
}
|
|
let update = false;
|
|
if (stream) {
|
|
if (!(name in peers)) {
|
|
update = true;
|
|
peers[name] = {
|
|
name: name,
|
|
local: true,
|
|
muted: true,
|
|
videoOn: false,
|
|
hasVideo: stream.video,
|
|
hasAudio: stream.audio,
|
|
attributes: {
|
|
local: true,
|
|
srcObject: stream.media
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
/* Renaming the local connection requires the peer to be deleted
|
|
* and re-established with the signaling server */
|
|
for (let key in peers) {
|
|
if (peers[key].local && key !== name) {
|
|
delete peers[key];
|
|
update = true;
|
|
}
|
|
}
|
|
|
|
if (update) {
|
|
if (debug) console.log(`media-agent - Setting global peers`, peers);
|
|
setPeers(Object.assign({}, peers));
|
|
}
|
|
}, [peers, name, setPeers, stream]);
|
|
|
|
useEffect(() => {
|
|
if (!ws || !name) {
|
|
return;
|
|
}
|
|
|
|
const setup_local_media = () => {
|
|
/* Ask user for permission to use the computers microphone and/or camera,
|
|
* attach it to an <audio> or <video> tag if they give us access. */
|
|
console.log(`media-agent - Requesting access to local ` +
|
|
`audio / video inputs`);
|
|
|
|
/* See Dummy Tracks for more ideas...
|
|
* https://blog.mozilla.org/webrtc/warm-up-with-replacetrack/
|
|
*/
|
|
navigator.getUserMedia = (navigator.getUserMedia ||
|
|
navigator.webkitGetUserMedia ||
|
|
navigator.mozGetUserMedia ||
|
|
navigator.msGetUserMedia);
|
|
|
|
return navigator.mediaDevices
|
|
.getUserMedia({audio: true, video: true})
|
|
.then((media) => {
|
|
return {
|
|
media: media,
|
|
audio: true,
|
|
video: true
|
|
};
|
|
})
|
|
.catch((error) => {
|
|
console.log(`media-agent - Access to audio and video ` +
|
|
`failed. Trying just audio.`);
|
|
return navigator.mediaDevices
|
|
.getUserMedia({ audio: true, video: false })
|
|
.then((media) => {
|
|
return {
|
|
media: media,
|
|
audio: true,
|
|
video: false
|
|
};
|
|
})
|
|
.catch((error) => {
|
|
console.log(`media-agent - Access to audio ` +
|
|
`failed.`);
|
|
return {
|
|
media: undefined,
|
|
audio: false,
|
|
video: false
|
|
};
|
|
});
|
|
})
|
|
.then((context) => { /* user accepted access to a/v */
|
|
sendMessage({type: 'media-status',
|
|
video: context.video,
|
|
audio: context.audio
|
|
});
|
|
|
|
if (context.video) {
|
|
console.log("media-agent - Access granted to audio/video");
|
|
context.media.getVideoTracks().forEach((track) => {
|
|
track.applyConstraints({
|
|
"video": {
|
|
"width": {
|
|
"min": 160,
|
|
"max": 320
|
|
},
|
|
"height": {
|
|
"min": 120,
|
|
"max": 240
|
|
}
|
|
}
|
|
});
|
|
});
|
|
return context;
|
|
}
|
|
|
|
const black = ({ width = 640, height = 480 } = {}) => {
|
|
const canvas = Object.assign(document.createElement("canvas"), {
|
|
width, height
|
|
});
|
|
canvas.getContext('2d').fillRect(0, 0, width, height);
|
|
const stream = canvas.captureStream();
|
|
return Object.assign(stream.getVideoTracks()[1], {
|
|
enabled: true
|
|
});
|
|
}
|
|
|
|
const silence = () => {
|
|
const ctx = new AudioContext(), oscillator = ctx.createOscillator();
|
|
const dst = oscillator.connect(ctx.createMediaStreamDestination());
|
|
oscillator.start();
|
|
return Object.assign(dst.stream.getAudioTracks()[0], {
|
|
enabled: true
|
|
});
|
|
}
|
|
|
|
if (context.audio) {
|
|
console.log("media-agent - Access granted to audio");
|
|
|
|
let black = ({ width = 640, height = 480 } = {}) => {
|
|
let canvas = Object.assign(document.createElement("canvas"), {
|
|
width, height
|
|
});
|
|
canvas.getContext('2d').fillRect(0, 0, width, height);
|
|
let stream = canvas.captureStream();
|
|
return Object.assign(stream.getVideoTracks()[0], {
|
|
enabled: true
|
|
});
|
|
}
|
|
context.media = new MediaStream([context.media, black()]);
|
|
return context;
|
|
}
|
|
|
|
context.media = new MediaStream([black(), silence()]);
|
|
return context;
|
|
});
|
|
};
|
|
|
|
let abort = false;
|
|
if (!stream) {
|
|
if (debug) console.log(`media-agent - WebSocket open request. ` +
|
|
`Attempting to create local media.`);
|
|
setup_local_media().then((context) => {
|
|
/* once the user has given us access to their
|
|
* microphone/camcorder, join the channel and start peering up */
|
|
if (abort) {
|
|
console.log(`media-agent - aborting setting local media`);
|
|
} else {
|
|
setStream(context);
|
|
}
|
|
}).catch((error) => { /* user denied access to a/v */
|
|
console.error(error);
|
|
console.log("media-agent - Access denied for audio/video");
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
abort = true;
|
|
if (!stream) {
|
|
console.log(`media-agent - abort media setup!`);
|
|
}
|
|
};
|
|
}, [ws, setStream, stream, name, sendMessage]);
|
|
|
|
return <></>;
|
|
}
|
|
|
|
const MediaControl = ({isSelf, peer, className}) => {
|
|
const [media, setMedia] = useState(undefined);
|
|
const [muted, setMuted] = useState(undefined);
|
|
const [videoOn, setVideoOn] = useState(undefined);
|
|
const [target, setTarget] = useState();
|
|
const [frame, setFrame] = useState({
|
|
translate: [0, 0],
|
|
});
|
|
|
|
useEffect(() => {
|
|
setTarget(document.querySelectorAll(".MediaControl")[0]);
|
|
}, []);
|
|
|
|
/* local state is used to trigger re-renders, and the global
|
|
* state is kept up to date in the peers object so re-assignment
|
|
* of sessions doesn't kill the peer or change the mute/video states */
|
|
useEffect(() => {
|
|
if (!peer) {
|
|
setMedia(undefined);
|
|
return;
|
|
}
|
|
setMuted(peer.muted);
|
|
setVideoOn(peer.videoOn);
|
|
setMedia(peer);
|
|
}, [peer, setMedia, setMuted, setVideoOn]);
|
|
|
|
console.log(`media-control - render`);
|
|
|
|
const toggleMute = (event) => {
|
|
if (debug) console.log(`media-control - toggleMute - ${peer.name}`,
|
|
!muted);
|
|
peer.muted = !muted;
|
|
setMuted(peer.muted);
|
|
event.stopPropagation();
|
|
}
|
|
|
|
const toggleVideo = (event) => {
|
|
if (debug) console.log(`media-control - toggleVideo - ${peer.name}`,
|
|
!videoOn);
|
|
peer.videoOn = !videoOn;
|
|
setVideoOn(peer.videoOn);
|
|
event.stopPropagation();
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!media || media.dead || !peer) {
|
|
return;
|
|
}
|
|
if (media.attributes.srcObject) {
|
|
console.log(`media-control - audio enable - ${peer.name}:${!muted}`);
|
|
media.attributes.srcObject.getAudioTracks().forEach((track) => {
|
|
track.enabled = media.hasAudio && !muted;
|
|
});
|
|
}
|
|
}); /* run after every render to hit when ontrack has received and set
|
|
* the stream //, [media, muted]); */
|
|
|
|
useEffect(() => {
|
|
if (!media || media.dead || !peer) {
|
|
return;
|
|
}
|
|
if (media.attributes.srcObject) {
|
|
console.log(`media-control - video enable - ${peer.name}:${videoOn}`);
|
|
media.attributes.srcObject.getVideoTracks().forEach((track) => {
|
|
track.enabled = media.hasVideo && videoOn;
|
|
});
|
|
}
|
|
}); /* run after every render to hit when ontrack has received and set
|
|
* the stream //, [media, videoOn]); */
|
|
|
|
const isValid = media && !media.dead,
|
|
colorAudio = (isValid && media.hasAudio) ? 'primary' : 'disabled',
|
|
colorVideo = (isValid && media.hasVideo) ? 'primary' : 'disabled';
|
|
|
|
return <>
|
|
<div className="MediaControlSpacer"/>
|
|
<div className={`MediaControl ${className}`}>
|
|
<div className="Controls" >
|
|
{ isSelf && <div onClick={toggleMute}>
|
|
{ muted && <MicOff color={colorAudio}/> }
|
|
{!muted && <Mic color={colorAudio}/> }
|
|
</div> }
|
|
{ !isSelf && <div onClick={toggleMute}>
|
|
{muted && <VolumeOff color={colorAudio}/> }
|
|
{!muted && <VolumeUp color={colorAudio}/> }
|
|
</div> }
|
|
<div onClick={toggleVideo}>
|
|
{ !videoOn && <VideocamOff color={colorVideo}/> }
|
|
{videoOn && <Videocam color={colorVideo}/> }
|
|
</div>
|
|
</div>
|
|
{ isValid && <>
|
|
<Moveable
|
|
draggable={true}
|
|
target={target}
|
|
resizable={true}
|
|
keepRatio={true}
|
|
throttleResize={0}
|
|
hideDefaultLines={false}
|
|
edge={true}
|
|
zoom={1}
|
|
origin={false}
|
|
onDragStart={e => {
|
|
e.set(frame.translate);
|
|
}}
|
|
onDrag={e => {
|
|
frame.translate = e.beforeTranslate;
|
|
}}
|
|
onResizeStart={e => {
|
|
e.setOrigin(["%", "%"]);
|
|
e.dragStart && e.dragStart.set(frame.translate);
|
|
}}
|
|
onResize={e => {
|
|
const { translate, rotate, transformOrigin } = frame;
|
|
e.target.style.width = `${e.width}px`;
|
|
e.target.style.height = `${e.height}px`;
|
|
e.target.style.transform = `translate(${translate[0]}px, ${translate[1]}px)`;
|
|
}}
|
|
onRender={e => {
|
|
const { translate, rotate, transformOrigin } = frame;
|
|
//e.target.style.transformOrigin = transformOrigin;
|
|
e.target.style.transform = `translate(${translate[0]}px, ${translate[1]}px)`;
|
|
}}
|
|
/><Video className="Video"
|
|
autoPlay='autoplay'
|
|
{...media.attributes}/>
|
|
</> }
|
|
{ !isValid && <video className="Video"></video> }
|
|
</div>
|
|
</>;
|
|
};
|
|
|
|
export { MediaControl, MediaAgent };
|