WIP
This commit is contained in:
parent
42778a9d46
commit
d1ae2059f7
39 changed files with 1735 additions and 428 deletions
|
|
@ -80,4 +80,13 @@ class Shaders {
|
|||
getProgram(name) {
|
||||
return this.programs[name];
|
||||
}
|
||||
|
||||
async loadArray(list, path) {
|
||||
let self = this;
|
||||
for (const e of list) {
|
||||
await self.loadShader(e, path)
|
||||
}
|
||||
await self.createProgramForEach(list)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
63
js/index.js
63
js/index.js
|
|
@ -1,22 +1,35 @@
|
|||
let shaderHandler, gl, c, actx, analyser, peak;
|
||||
let positionData = [];
|
||||
let positionSize = 8192 * 2 * 2;
|
||||
let shaderHandler, gl, c, actx, analyser, peak, isInit = false;
|
||||
|
||||
function createAudioContextStream(stream) {
|
||||
function createAudioContextStream(audio) {
|
||||
let AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
if (actx) {
|
||||
actx.close();
|
||||
}
|
||||
actx = new AudioContext();
|
||||
analyser = actx.createAnalyser();
|
||||
let MEDIA_ELEMENT_NODES = new WeakMap();
|
||||
let Source;
|
||||
|
||||
analyser.fftSize = 4096;
|
||||
analyser.maxDecibels = 0;
|
||||
analyser.smoothingTimeConstant = .4;
|
||||
Source = actx.createMediaStreamSource(stream);
|
||||
if (audio) {
|
||||
if (MEDIA_ELEMENT_NODES.has(audio)) {
|
||||
Source = MEDIA_ELEMENT_NODES.get(audio);
|
||||
} else {
|
||||
Source = actx.createMediaElementSource(audio);
|
||||
MEDIA_ELEMENT_NODES.set(audio, Source);
|
||||
}
|
||||
|
||||
Source.connect(analyser);
|
||||
Source.connect(analyser);
|
||||
analyser.connect(actx.destination);
|
||||
audio.oncanplay = () => {
|
||||
actx.resume();
|
||||
};
|
||||
audio.onended = function () {
|
||||
actx.pause();
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -28,26 +41,38 @@ async function init() {
|
|||
return false;
|
||||
}
|
||||
shaderHandler = new Shaders(gl);
|
||||
await shaderHandler.loadShader("test", "shaders/");
|
||||
await shaderHandler.loadShader("wave", "shaders/");
|
||||
|
||||
//sphere shader
|
||||
await shaderHandler.loadShader("sphere", "shaders/", gl.VERTEX_SHADER);
|
||||
return shaderHandler.createProgramForEach(["test", "sphere"]);
|
||||
return shaderHandler.createProgramForEach(["wave", "sphere"]);
|
||||
}
|
||||
|
||||
(function () {
|
||||
navigator.mediaDevices.getUserMedia({audio: true, video: false})
|
||||
.then(createAudioContextStream).then(e => {
|
||||
generateRotationSliders();
|
||||
generateColorSliders();
|
||||
generateWorldSliders();
|
||||
generateDrawSliders();
|
||||
generateTranslateSliders();
|
||||
function createView() {
|
||||
if (!isInit) {
|
||||
createAudioContextStream(audioFile);
|
||||
init().then(b => {
|
||||
if (b) {
|
||||
sphereObject.drawMode = gl.POINTS;
|
||||
loadConfig();
|
||||
draw();
|
||||
}
|
||||
})
|
||||
});
|
||||
})();
|
||||
isInit = true;
|
||||
}
|
||||
}
|
||||
|
||||
function initGUI() {
|
||||
generateRotationSliders();
|
||||
generateColorSliders();
|
||||
generateWorldSliders();
|
||||
generateDrawSliders();
|
||||
generateTranslateSliders();
|
||||
}
|
||||
|
||||
(function () {
|
||||
if (document.readyState === "complete" || document.readyState === "interactive") {
|
||||
initGUI()
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', initGUI);
|
||||
}
|
||||
})()
|
||||
88
js/old.js
88
js/old.js
|
|
@ -1,88 +0,0 @@
|
|||
function readData() {
|
||||
let items = analyser.fftSize;
|
||||
let dataArray = new Float32Array(items);
|
||||
analyser.getFloatTimeDomainData(dataArray);
|
||||
let space = 255 / (items + 2);
|
||||
let pos = [0, 127.5];
|
||||
let x = space;
|
||||
peak = 0;
|
||||
for (let i = 0; i < items; i++) {
|
||||
let data = (dataArray[i] * 125) + 127.5
|
||||
if (Math.abs(data) > peak) {
|
||||
peak = data;
|
||||
}
|
||||
pos.push(x);
|
||||
pos.push(data);
|
||||
x += space;
|
||||
}
|
||||
pos.push(255, 127.5);
|
||||
positions = pos;
|
||||
}
|
||||
|
||||
let positions = [
|
||||
Math.random(), Math.random(),
|
||||
Math.random(), Math.random(),
|
||||
Math.random(), Math.random(),
|
||||
];
|
||||
|
||||
/*function draw() {
|
||||
readData();
|
||||
let program = shaderHandler.getProgram("test");
|
||||
let positionAttributeLocation = gl.getAttribLocation(program, "a_position");
|
||||
var colorLocation = gl.getUniformLocation(program, "u_color");
|
||||
let positionBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW);
|
||||
let vao = gl.createVertexArray();
|
||||
gl.bindVertexArray(vao);
|
||||
gl.enableVertexAttribArray(positionAttributeLocation);
|
||||
gl.vertexAttribPointer(
|
||||
positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
|
||||
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
gl.useProgram(program);
|
||||
gl.uniform4f(colorLocation, peak / 255, (255 - peak) / 255, .2, 1);
|
||||
gl.drawArrays(gl.LINE_STRIP, 0, positions.length / 2);
|
||||
|
||||
let positionBuffer2 = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer2);
|
||||
gl.uniform4f(colorLocation, 0, 0, 1, .3);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW);
|
||||
gl.drawArrays(gl.POINTS, 0, positions.length / 2);
|
||||
|
||||
requestAnimationFrame(draw);
|
||||
}*/
|
||||
|
||||
function setupSphere() {
|
||||
sphereData = [];
|
||||
let data = readData(),
|
||||
radData = data[0];
|
||||
let sin = Math.sin,
|
||||
cos = Math.cos,
|
||||
total = sphereObject.total || 50,
|
||||
cTotal = (total + 1) * (total + 1),
|
||||
r = sphereObject.radius || 1000,
|
||||
rx = r / c.width,
|
||||
ry = r / c.height,
|
||||
counter = 0;
|
||||
for (let i = 1; i <= total; i++) {
|
||||
let lat = VTUtils.map(i, 0, total, 0, Math.PI, false);
|
||||
for (let j = 1; j <= total; j++) {
|
||||
let lon = VTUtils.map(j, 0, total, 0, Math.TWO_PI, false);
|
||||
let map = VTUtils.map(counter, 0, cTotal, 0, radData.length - 1, false);
|
||||
map = Math.floor(map);
|
||||
let realRX = rx + radData[map];
|
||||
let realRY = ry + radData[map];
|
||||
let x = rx * sin(lon) * cos(lat);
|
||||
let y = rx * sin(lon) * sin(lat);
|
||||
let z = ry * cos(lon);
|
||||
sphereData.push(x);
|
||||
sphereData.push(y);
|
||||
sphereData.push(z);
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
return [VTUtils.hsvToRgb(data[1], 1, 1), data[1]];
|
||||
}
|
||||
48
js/sphere.js
48
js/sphere.js
|
|
@ -7,20 +7,21 @@ let sphereObject = {
|
|||
rotation: [0, 0, 0], //radians
|
||||
rotateInc: [0.0, 0.0, 0.0], //degreesInc
|
||||
rotateByBeat: true,
|
||||
translate: [1, 1, 1],
|
||||
translate: [0, 0, 0],
|
||||
total: 50,
|
||||
radius: 500,
|
||||
color: {r: 0, g: 0, b: 0},
|
||||
colorByBeat: true,
|
||||
drawMode: 0,
|
||||
drawMode: 5,
|
||||
sphereMode: 0,
|
||||
lightPos: [0, 0, 0],
|
||||
pointSize: 2,
|
||||
steps: 512,
|
||||
dirtyMode: false
|
||||
dirtyMode: false,
|
||||
light: 0.3
|
||||
}
|
||||
|
||||
function readData() {
|
||||
function readDataBar() {
|
||||
let items = sphereObject.steps;
|
||||
let dataArray = new Uint8Array(items);
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
|
@ -36,6 +37,7 @@ function readData() {
|
|||
|
||||
let sphereDataVectors = [], lastTotal = 0;
|
||||
|
||||
// avoid garbage collection each run
|
||||
function prepareData() {
|
||||
let total = sphereObject.total;
|
||||
if (lastTotal !== total) {
|
||||
|
|
@ -52,7 +54,7 @@ function prepareData() {
|
|||
|
||||
function setupSphere() {
|
||||
sphereData = [];
|
||||
let data = readData(),
|
||||
let data = readDataBar(),
|
||||
radData = data[0],
|
||||
map = VTUtils.map,
|
||||
total = sphereObject.total,
|
||||
|
|
@ -94,14 +96,21 @@ function setupSphere() {
|
|||
|
||||
function getAddRad(counter, data, total) {
|
||||
let mapping, rAdd, map = VTUtils.map;
|
||||
let h = total / 2;
|
||||
if (sphereObject.sphereMode === 3) {
|
||||
let h = total / 2;
|
||||
if (counter > h) {
|
||||
mapping = map(counter, h, total, data.length - 1, 0);
|
||||
} else {
|
||||
mapping = map(counter, 0, h, 0, data.length - 1);
|
||||
}
|
||||
rAdd = data[Math.round(mapping)] || 0;
|
||||
} else if (sphereObject.sphereMode === 4) {
|
||||
if (counter > h) {
|
||||
mapping = map(counter, h, total, 0, data.length - 1);
|
||||
} else {
|
||||
mapping = map(counter, 0, h, data.length - 1, 0);
|
||||
}
|
||||
rAdd = data[Math.round(mapping)] || 0;
|
||||
} else {
|
||||
mapping = map(counter, 0, total, 0, data.length - 1);
|
||||
rAdd = data[Math.round(mapping)] || 0;
|
||||
|
|
@ -109,18 +118,16 @@ function getAddRad(counter, data, total) {
|
|||
return rAdd;
|
||||
}
|
||||
|
||||
let position, world, color, rotate, light, lightPos, lightAngle = 90;
|
||||
let position, color, rotate, light, lightPos;
|
||||
|
||||
function prepare(program) {
|
||||
position = gl.getAttribLocation(program, "a_position");
|
||||
world = gl.getUniformLocation(program, 'u_world');
|
||||
color = gl.getUniformLocation(program, "u_color");
|
||||
light = gl.getUniformLocation(program, "u_light");
|
||||
rotate = gl.getUniformLocation(program, "u_matrix");
|
||||
lightPos = gl.getUniformLocation(program, "u_lightPos");
|
||||
let pointSize = gl.getUniformLocation(program, "u_pointSize");
|
||||
gl.uniformMatrix4fv(world, false, TDUtils.yRotation(TDUtils.degToRad(lightAngle)));
|
||||
gl.uniform3fv(light, [0.95, 0.62, 0.094]);
|
||||
gl.uniform3fv(light, [sphereObject.light, 0, 0]);
|
||||
gl.uniform3fv(lightPos, sphereObject.lightPos);
|
||||
gl.uniform1f(pointSize, sphereObject.pointSize);
|
||||
}
|
||||
|
|
@ -135,16 +142,15 @@ function draw() {
|
|||
gl.useProgram(program);
|
||||
prepare(program);
|
||||
let matrix = [
|
||||
sphereObject.translate[0] / aspect, 0, 0, 0,
|
||||
0, sphereObject.translate[1], 0, 0,
|
||||
0, 0, sphereObject.translate[2], 0,
|
||||
0, 0, 0, 1
|
||||
1 / aspect, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
sphereObject.translate[0], sphereObject.translate[1], sphereObject.translate[2], 1
|
||||
]
|
||||
matrix = TDUtils.multiply(matrix, TDUtils.xRotation(sphereObject.rotation[0]));
|
||||
matrix = TDUtils.multiply(matrix, TDUtils.yRotation(sphereObject.rotation[1]));
|
||||
matrix = TDUtils.multiply(matrix, TDUtils.zRotation(sphereObject.rotation[2]));
|
||||
gl.uniformMatrix4fv(rotate, false, matrix);
|
||||
|
||||
let positionBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sphereData), gl.DYNAMIC_DRAW);
|
||||
|
|
@ -152,7 +158,6 @@ function draw() {
|
|||
gl.bindVertexArray(vao);
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 3, gl.FLOAT, true, 0, 0);
|
||||
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.depthFunc(gl.LEQUAL);
|
||||
|
|
@ -186,12 +191,6 @@ function sphereMode(lat, lon, i, counter, rx, ry) {
|
|||
sin = Math.sin,
|
||||
cos = Math.cos;
|
||||
switch (sphereObject.sphereMode) {
|
||||
case 0:
|
||||
case 3:
|
||||
x = rx * sin(lat) * cos(lon);
|
||||
y = ry * sin(lat) * sin(lon);
|
||||
z = ry * cos(lat);
|
||||
break;
|
||||
case 1:
|
||||
x = rx * sin(lon) * cos(lat);
|
||||
y = ry * sin(lon) * sin(lat);
|
||||
|
|
@ -202,6 +201,11 @@ function sphereMode(lat, lon, i, counter, rx, ry) {
|
|||
y = ry * sin(lat) * sin(lat);
|
||||
z = ry * cos(lon);
|
||||
break;
|
||||
default:
|
||||
x = rx * sin(lat) * cos(lon);
|
||||
y = ry * sin(lat) * sin(lon);
|
||||
z = ry * cos(lat);
|
||||
break;
|
||||
}
|
||||
return {x: x, y: y, z: z};
|
||||
}
|
||||
39
js/utils.js
39
js/utils.js
|
|
@ -131,7 +131,7 @@ class VTVector {
|
|||
this.z += z;
|
||||
}
|
||||
|
||||
setXYZ(x,y,z) {
|
||||
setXYZ(x, y, z) {
|
||||
this.x = x || 0;
|
||||
this.y = y || 0;
|
||||
this.z = z || 0;
|
||||
|
|
@ -236,3 +236,40 @@ class TDUtils {
|
|||
return d * Math.PI / 180;
|
||||
}
|
||||
}
|
||||
|
||||
function $(sel, s) {
|
||||
return $$(sel, s)[0];
|
||||
}
|
||||
|
||||
function $$(sel, s) {
|
||||
s = s || document;
|
||||
return s.querySelectorAll(sel);
|
||||
}
|
||||
|
||||
Node.prototype.addDelegatedEventListener = function (type, aim, cb) {
|
||||
this.addEventListener(type, (event) => {
|
||||
let target = event.target;
|
||||
if (target.matches(aim)) {
|
||||
cb(event, target);
|
||||
} else {
|
||||
let parent = target.closest(aim);
|
||||
if (parent) {
|
||||
cb(event, parent);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
function create(name, content) {
|
||||
let d = document.createElement(name);
|
||||
if (content) {
|
||||
d.innerHTML = content;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function append(to, array) {
|
||||
for (let item in array) {
|
||||
to.appendChild(array[item]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +1,3 @@
|
|||
function $(sel, s) {
|
||||
return $$(sel, s)[0];
|
||||
}
|
||||
|
||||
function $$(sel, s) {
|
||||
s = s || document;
|
||||
return s.querySelectorAll(sel);
|
||||
}
|
||||
|
||||
Node.prototype.addDelegatedEventListener = function (type, aim, cb) {
|
||||
this.addEventListener(type, (event) => {
|
||||
let target = event.target;
|
||||
if (target.matches(aim)) {
|
||||
cb(event, target);
|
||||
} else {
|
||||
let parent = target.closest(aim);
|
||||
if (parent) {
|
||||
cb(event, parent);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
function create(name, content) {
|
||||
let d = document.createElement(name);
|
||||
if (content) {
|
||||
d.innerHTML = content;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function append(to, array) {
|
||||
for (let item in array) {
|
||||
to.appendChild(array[item]);
|
||||
}
|
||||
}
|
||||
|
||||
function generateRotationSliders() {
|
||||
let group = $('#rotate');
|
||||
generateSlider(["X", "Y", "Z", "X-Inc", "Y-Inc", "Z-Inc"], 0, 360, 0, group, "rotate");
|
||||
|
|
@ -43,7 +6,7 @@ function generateRotationSliders() {
|
|||
|
||||
function generateTranslateSliders() {
|
||||
let group = $('#translate');
|
||||
generateSlider(["X", "Y", "Z"], -1, 1, 1, group, "translate");
|
||||
generateSlider(["X", "Y", "Z"], -1, 1, 0, group, "translate");
|
||||
}
|
||||
|
||||
function generateColorSliders() {
|
||||
|
|
@ -54,20 +17,20 @@ function generateColorSliders() {
|
|||
|
||||
function generateWorldSliders() {
|
||||
let group = $('#world');
|
||||
generateSlider(["LightPos-X", "LightPos-Y", "LightPos-Z"], -1, 1, 1, group, "light");
|
||||
generateSlider(["LightAngle"], 0, 360, 90, group, "light");
|
||||
generateSlider(["LightPos-X", "LightPos-Y", "LightPos-Z"], -100, 100, 0, group, "light");
|
||||
generateSlider(["Light"], 0, 1, 0.3, group, "light");
|
||||
}
|
||||
|
||||
function generateDrawSliders() {
|
||||
let group = $('#draw'),
|
||||
g = "drawMode"
|
||||
generateSlider(["DrawMode"], 0, 6, 0, group, g);
|
||||
generateSlider(["Form"], 0, 3, 0, group, g);
|
||||
generateSlider(["Form"], 0, 4, 0, group, g);
|
||||
generateSlider(["Radius"], 20, 1500, 500, group, g);
|
||||
generateSlider(["Total"], 0, 200, 50, group, g);
|
||||
generateSlider(["Total"], 20, 250, 50, group, g);
|
||||
generateSlider(["PointSize"], 1, 10, 2, group, g, .2);
|
||||
generateSlider(["Smoothing"], 0, 1, 0.8, group, g, .05);
|
||||
generateSlider(["Steps"], 512, analyser.fftSize / 2, 512, group, g, 256);
|
||||
generateSlider(["Smoothing"], 0, 100, 80, group, g, 1);
|
||||
generateSlider(["Steps"], 256, 2048, 512, group, g, 16);
|
||||
generateCheckBox(["Dirty"], false, group, g)
|
||||
}
|
||||
|
||||
|
|
@ -102,11 +65,10 @@ function setColors() {
|
|||
|
||||
function setWorld() {
|
||||
let group = $('#world');
|
||||
sphereObject.lightPos[0] = getValue($('#LightPos-X', group)) * 0.2;
|
||||
sphereObject.lightPos[1] = getValue($('#LightPos-Y', group)) * 0.2;
|
||||
sphereObject.lightPos[2] = getValue($('#LightPos-Z', group)) * 0.2;
|
||||
|
||||
lightAngle = getValue($('#LightAngle', group))
|
||||
sphereObject.lightPos[0] = getValue($('#LightPos-X', group));
|
||||
sphereObject.lightPos[1] = getValue($('#LightPos-Y', group));
|
||||
sphereObject.lightPos[2] = getValue($('#LightPos-Z', group));
|
||||
sphereObject.light = getValue($('#Light', group));
|
||||
}
|
||||
|
||||
function setDraw() {
|
||||
|
|
@ -118,7 +80,7 @@ function setDraw() {
|
|||
sphereObject.pointSize = getValue($('#PointSize', group));
|
||||
sphereObject.steps = getValue($('#Steps', group));
|
||||
sphereObject.dirtyMode = $('#drawModeDirty', group).checked;
|
||||
analyser.smoothingTimeConstant = getValue($('#Smoothing', group));
|
||||
analyser.smoothingTimeConstant = getValue($('#Smoothing', group)) / 100;
|
||||
}
|
||||
|
||||
function setTranslate() {
|
||||
|
|
@ -183,6 +145,7 @@ function changeHandler(el) {
|
|||
} else if (d === "drawMode") {
|
||||
setDraw();
|
||||
}
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
document.body.addDelegatedEventListener('input', 'group-input input', (ev, el) => {
|
||||
|
|
@ -205,4 +168,60 @@ function getValue(slider) {
|
|||
|
||||
$('.settings-icon').addEventListener('click', function () {
|
||||
$('.off-can').classList.toggle("closed");
|
||||
$('.settings-icon').classList.toggle("open");
|
||||
})
|
||||
|
||||
window.addEventListener('keyup', e => {
|
||||
if (e.key === 'F11') {
|
||||
c.requestFullscreen();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().then(console.log);
|
||||
}
|
||||
}
|
||||
if (e.key === 'p') {
|
||||
audioFile.play();
|
||||
}
|
||||
if (e.key === 's') {
|
||||
audioFile.pause();
|
||||
}
|
||||
});
|
||||
|
||||
function saveConfig() {
|
||||
localStorage.setItem('config-sphere', JSON.stringify(sphereObject));
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
let item = localStorage.getItem('config-sphere');
|
||||
if (item && item !== "") {
|
||||
sphereObject = JSON.parse(item);
|
||||
}
|
||||
}
|
||||
|
||||
let uploadField = $('#upload');
|
||||
let audioFile = new Audio();
|
||||
let lastSong = null;
|
||||
uploadField.addEventListener('change', e => {
|
||||
let file = uploadField.files[0];
|
||||
if (file && file.type.indexOf('audio') !== -1 && file.name.match(".m3u") === null && file.name.match(".wma") === null) {
|
||||
if (lastSong) {
|
||||
URL.revokeObjectURL(lastSong);
|
||||
}
|
||||
audioFile.src = URL.createObjectURL(file);
|
||||
document.title = file.name;
|
||||
lastSong = audioFile.src;
|
||||
createView();
|
||||
}
|
||||
})
|
||||
|
||||
$('#play').addEventListener('click', e => {
|
||||
if (audioFile.src !== "") {
|
||||
c.requestFullscreen().then(r => {
|
||||
setTimeout(x => {
|
||||
audioFile.play();
|
||||
}, 1000)
|
||||
});
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue