Add base files from AME
This commit is contained in:
parent
e8f3881513
commit
e3c3bded3a
129 changed files with 19056 additions and 0 deletions
591
resources/js/eq.js
Normal file
591
resources/js/eq.js
Normal file
|
@ -0,0 +1,591 @@
|
|||
var EAOverride = true;
|
||||
var AErecorderNode;
|
||||
var GCOverride = true;
|
||||
var outputID = -1;
|
||||
var EAoutputID = -1;
|
||||
var queueExclusive = false;
|
||||
var queueChromecast = false;
|
||||
var selectedGC ;
|
||||
var MVsource;
|
||||
var windowAudioNode;
|
||||
const workerOptions = {
|
||||
OggOpusEncoderWasmPath: 'https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/OggOpusEncoder.wasm',
|
||||
WebMOpusEncoderWasmPath: 'https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/WebMOpusEncoder.wasm'
|
||||
};
|
||||
var recorder;
|
||||
|
||||
window.MediaRecorder = OpusMediaRecorder;
|
||||
|
||||
var audioWorklet = `class RecorderWorkletProcessor extends AudioWorkletProcessor {
|
||||
static get parameterDescriptors() {
|
||||
return [{
|
||||
name: 'isRecording',
|
||||
defaultValue: 0
|
||||
},
|
||||
{
|
||||
name: 'numberOfChannels',
|
||||
defaultValue: 2
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._bufferSize = 32768;
|
||||
this._buffers = null;
|
||||
this._initBuffer();
|
||||
}
|
||||
|
||||
_initBuffers(numberOfChannels) {
|
||||
this._buffers = [];
|
||||
for (let channel=0; channel < numberOfChannels; channel++) {
|
||||
this._buffers.push(new Float32Array(this._bufferSize));
|
||||
}
|
||||
}
|
||||
|
||||
_initBuffer() {
|
||||
this._bytesWritten = 0;
|
||||
}
|
||||
|
||||
_isBufferEmpty() {
|
||||
return this._bytesWritten === 0;
|
||||
}
|
||||
|
||||
_isBufferFull() {
|
||||
return this._bytesWritten === this._bufferSize;
|
||||
}
|
||||
|
||||
|
||||
_pushToBuffers(audioRawData, numberOfChannels) {
|
||||
if (this._isBufferFull()) {
|
||||
this._flush();
|
||||
}
|
||||
|
||||
let dataLength = audioRawData[0].length;
|
||||
|
||||
for (let idx=0; idx<dataLength; idx++) {
|
||||
for (let channel=0; channel < numberOfChannels; channel++) {
|
||||
let value = audioRawData[channel][idx];
|
||||
this._buffers[channel][this._bytesWritten] = value;
|
||||
}
|
||||
this._bytesWritten += 1;
|
||||
}
|
||||
}
|
||||
|
||||
_flush() {
|
||||
let buffers = [];
|
||||
this._buffers.forEach((buffer, channel) => {
|
||||
if (this._bytesWritten < this._bufferSize) {
|
||||
buffer = buffer.slice(0, this._bytesWritten);
|
||||
}
|
||||
buffers[channel] = buffer;
|
||||
});
|
||||
this.port.postMessage({
|
||||
eventType: 'data',
|
||||
audioBuffer: buffers,
|
||||
bufferSize: this._bufferSize
|
||||
});
|
||||
this._initBuffer();
|
||||
}
|
||||
|
||||
_recordingStopped() {
|
||||
this.port.postMessage({
|
||||
eventType: 'stop'
|
||||
});
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const isRecordingValues = parameters.isRecording;
|
||||
const numberOfChannels = parameters.numberOfChannels[0];
|
||||
if (this._buffers === null) {
|
||||
this._initBuffers(numberOfChannels);
|
||||
}
|
||||
|
||||
for (let dataIndex = 0; dataIndex < isRecordingValues.length; dataIndex++)
|
||||
{
|
||||
const shouldRecord = isRecordingValues[dataIndex] === 1;
|
||||
if (!shouldRecord && !this._isBufferEmpty()) {
|
||||
this._flush();
|
||||
this._recordingStopped();
|
||||
}
|
||||
|
||||
if (shouldRecord) {
|
||||
let audioRawData = inputs[0];
|
||||
this._pushToBuffers(audioRawData, numberOfChannels);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
registerProcessor('recorder-worklet', RecorderWorkletProcessor);`;
|
||||
var GCstream;
|
||||
var searchInt;
|
||||
var AMEx = {
|
||||
context: new AudioContext(),
|
||||
result: {},
|
||||
filter: [],
|
||||
EQRanges: [{
|
||||
f: 32,
|
||||
type: 'lowshelf'
|
||||
}, {
|
||||
f: 64,
|
||||
type: 'peaking'
|
||||
}, {
|
||||
f: 125,
|
||||
type: 'peaking'
|
||||
}, {
|
||||
f: 250,
|
||||
type: 'peaking'
|
||||
}, {
|
||||
f: 500,
|
||||
type: 'peaking'
|
||||
}, {
|
||||
f: 1000,
|
||||
type: 'peaking'
|
||||
}, {
|
||||
f: 2000,
|
||||
type: 'peaking'
|
||||
}, {
|
||||
f: 4000,
|
||||
type: 'peaking'
|
||||
}, {
|
||||
f: 8000,
|
||||
type: 'peaking'
|
||||
}, {
|
||||
f: 16000,
|
||||
type: 'highshelf'
|
||||
}]
|
||||
};
|
||||
var bassFilter;
|
||||
var trebleFilter;
|
||||
|
||||
var AudioOutputs = {
|
||||
fInit: false,
|
||||
eqReady: false,
|
||||
activeCasts: [],
|
||||
castUI() {
|
||||
AMJavaScript.getRequest("ameres://html/cast_device.html", (content) => {
|
||||
var vm = new Vue({
|
||||
data: {
|
||||
devices: {
|
||||
cast: [],
|
||||
airplay: []
|
||||
},
|
||||
scanning: false,
|
||||
activeCasts: AudioOutputs.activeCasts
|
||||
},
|
||||
methods: {
|
||||
scan() {
|
||||
let self = this;
|
||||
this.scanning = true;
|
||||
AudioOutputs.getGCDevices();
|
||||
setTimeout(()=>{
|
||||
self.devices.cast = ipcRenderer.sendSync("getKnownCastDevices");
|
||||
self.scanning = false;
|
||||
}, 2000);
|
||||
console.log(this.devices);
|
||||
vm.$forceUpdate();
|
||||
},
|
||||
setCast(device) {
|
||||
console.log(`requesting: ${device}`);
|
||||
AudioOutputs.playGC(device);
|
||||
},
|
||||
stopCasting() {
|
||||
AudioOutputs.stopGC();
|
||||
this.activeCasts = AudioOutputs.activeCasts;
|
||||
vm.$forceUpdate();
|
||||
},
|
||||
close() {
|
||||
modal.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
var modal = new AMEModal({
|
||||
content: content,
|
||||
CloseButton: false,
|
||||
Style: {
|
||||
maxWidth: "600px"
|
||||
},
|
||||
OnCreate() {
|
||||
vm.$mount("#castdevices-vue");
|
||||
vm.scan();
|
||||
},
|
||||
OnClose() {
|
||||
_vues.destroy(vm);
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
init: function (cb = function () {}) {
|
||||
AudioOutputs.fInit = true;
|
||||
searchInt = setInterval(function () {
|
||||
if (document.getElementById("apple-music-player")) {
|
||||
AudioOutputs.eqReady = true;
|
||||
document.getElementById("apple-music-player").crossOrigin = "anonymous";
|
||||
AudioOutputs.amplifyMedia(document.getElementById("apple-music-player"), 0);
|
||||
var context = AMEx.context;
|
||||
var source = AMEx.result.source;
|
||||
bassFilter = context.createBiquadFilter();
|
||||
bassFilter.type = "lowshelf";
|
||||
bassFilter.frequency.value = 200;
|
||||
bassFilter.gain.value = 0;
|
||||
|
||||
trebleFilter = context.createBiquadFilter();
|
||||
trebleFilter.type = "highshelf";
|
||||
trebleFilter.frequency.value = 2000;
|
||||
trebleFilter.gain.value = 0;
|
||||
|
||||
source.connect(bassFilter);
|
||||
bassFilter.connect(trebleFilter);
|
||||
trebleFilter.connect(context.destination);
|
||||
console.log("Attached EQ");
|
||||
|
||||
if (queueExclusive){
|
||||
console.log('we good');
|
||||
AudioOutputs.startExclusiveAudio(outputID); }
|
||||
|
||||
cb();
|
||||
clearInterval(searchInt);
|
||||
}
|
||||
}, 1000);
|
||||
waitFor(()=>{return queueChromecast &&
|
||||
((document.getElementById("apple-music-player") != null&&
|
||||
document.getElementById("apple-music-player").readyState == 4) || (
|
||||
document.querySelector('apple-music-video-player') &&
|
||||
document.querySelector('apple-music-video-player').shadowRoot &&
|
||||
document.querySelector('apple-music-video-player').shadowRoot.querySelector('amp-video-player-internal') &&
|
||||
document.querySelector('apple-music-video-player').shadowRoot.querySelector('amp-video-player-internal').shadowRoot &&
|
||||
document.querySelector('apple-music-video-player').shadowRoot.querySelector('amp-video-player-internal').shadowRoot.querySelector('amp-video-player') &&
|
||||
document.querySelector('apple-music-video-player').shadowRoot.querySelector('amp-video-player-internal').shadowRoot.querySelector('amp-video-player').shadowRoot &&
|
||||
document.querySelector('apple-music-video-player').shadowRoot.querySelector('amp-video-player-internal').shadowRoot.querySelector('amp-video-player').shadowRoot.getElementById('apple-music-video-player') &&
|
||||
document.querySelector('apple-music-video-player').shadowRoot.querySelector('amp-video-player-internal').shadowRoot.querySelector('amp-video-player').shadowRoot.getElementById('apple-music-video-player').readyState == 4)) ;},() => AudioOutputs.playGC(selectedGC))
|
||||
|
||||
},
|
||||
amplifyMedia: function (mediaElem, multiplier) {
|
||||
AMEx.context = new(window.AudioContext || window.webkitAudioContext),
|
||||
AMEx.result = {
|
||||
context: AMEx.context,
|
||||
source: AMEx.context.createMediaElementSource(mediaElem),
|
||||
gain: AMEx.context.createGain(),
|
||||
media: mediaElem,
|
||||
amplify: function (multiplier) {
|
||||
AMEx.result.gain.gain.value = multiplier;
|
||||
},
|
||||
getAmpLevel: function () {
|
||||
return AMEx.result.gain.gain.value;
|
||||
}
|
||||
};
|
||||
AMEx.result.source.connect(AMEx.result.gain);
|
||||
AMEx.result.gain.connect(AMEx.context.destination);
|
||||
AMEx.result.amplify(multiplier);
|
||||
return AMEx.result;
|
||||
},
|
||||
popup_generic: function ({
|
||||
title = "",
|
||||
content = document.createElement("div"),
|
||||
closefn = function () {},
|
||||
transparentBg = false,
|
||||
windowStyle = {},
|
||||
backdropStyle = {}
|
||||
}) {
|
||||
let backdrop = document.createElement("div");
|
||||
backdrop.style.width = "100%";
|
||||
backdrop.style.height = "100%";
|
||||
backdrop.style.position = "fixed";
|
||||
backdrop.style.top = 0;
|
||||
backdrop.style.left = 0;
|
||||
if (!transparentBg) {
|
||||
backdrop.style.background = "rgba(0,0,0,0.5)";
|
||||
} else {
|
||||
backdrop.style.background = "rgba(0,0,0,0.0)";
|
||||
};
|
||||
backdrop.style.zIndex = 10000;
|
||||
backdrop.style.display = "flex";
|
||||
backdrop.style.alignItems = "center";
|
||||
backdrop.style.justifyContent = "center";
|
||||
let win = document.createElement("div");
|
||||
win.style.width = "300px";
|
||||
win.style.background = "var(--modalBGColor)";
|
||||
win.style.zIndex = 10000;
|
||||
win.style.padding = "16px";
|
||||
win.style.borderRadius = "10px";
|
||||
Object.assign(backdrop.style, backdropStyle);
|
||||
Object.assign(win.style, windowStyle);
|
||||
let closeBtn = document.createElement("button");
|
||||
closeBtn.style.background = "var(--primaryColor)";
|
||||
closeBtn.style.borderRadius = "4px";
|
||||
closeBtn.style.padding = "8px 0px 8px 0px";
|
||||
closeBtn.style.width = "100%";
|
||||
closeBtn.style.fontWeight = "bold";
|
||||
closeBtn.style.margin = "12px 0px 0px 0px";
|
||||
closeBtn.innerHTML = "Close";
|
||||
closeBtn.id = "eq-close";
|
||||
closeBtn.addEventListener("click", function () {
|
||||
backdrop.remove();
|
||||
closefn();
|
||||
});
|
||||
let titleText = document.createElement("div");
|
||||
titleText.innerHTML = (title);
|
||||
titleText.style.fontWeight = "bold";
|
||||
|
||||
|
||||
win.appendChild(titleText);
|
||||
win.appendChild(content);
|
||||
win.appendChild(closeBtn);
|
||||
|
||||
backdrop.appendChild(win);
|
||||
document.body.appendChild(backdrop);
|
||||
},
|
||||
ShowEQ: function () {
|
||||
if (!AudioOutputs.eqReady) {
|
||||
alert("Audio is not ready, Play a song to use this function.");
|
||||
};
|
||||
let backdrop = document.createElement("div");
|
||||
backdrop.style.width = "100%";
|
||||
backdrop.style.height = "100%";
|
||||
backdrop.style.position = "fixed";
|
||||
backdrop.style.top = 0;
|
||||
backdrop.style.left = 0;
|
||||
backdrop.style.background = "rgba(0,0,0,0.5)";
|
||||
backdrop.style.zIndex = 9999;
|
||||
backdrop.style.display = "flex";
|
||||
backdrop.style.alignItems = "center";
|
||||
backdrop.style.justifyContent = "center";
|
||||
backdrop.style.backdropFilter = "blur(12px) saturate(180%)";
|
||||
|
||||
let win = document.createElement("div");
|
||||
win.style.width = "300px";
|
||||
win.style.background = "var(--modalBGColor)";
|
||||
win.style.zIndex = 10000;
|
||||
win.style.padding = "16px";
|
||||
win.style.borderRadius = "10px";
|
||||
|
||||
|
||||
let closeBtn = document.createElement("button");
|
||||
closeBtn.style.background = "var(--primaryColor)";
|
||||
closeBtn.style.borderRadius = "4px";
|
||||
closeBtn.style.padding = "8px 0px 8px 0px";
|
||||
closeBtn.style.width = "100%";
|
||||
closeBtn.style.fontWeight = "bold";
|
||||
closeBtn.style.margin = "12px 0px 0px 0px";
|
||||
|
||||
closeBtn.innerHTML = "Close";
|
||||
closeBtn.addEventListener("click", function () {
|
||||
backdrop.remove()
|
||||
});
|
||||
|
||||
let titleText = document.createElement("div");
|
||||
let bassText = document.createElement("div");
|
||||
let trebleText = document.createElement("div");
|
||||
let gainText = document.createElement("div");
|
||||
titleText.id = 'eq-menu';
|
||||
titleText.innerHTML = (`Equalizer`);
|
||||
titleText.style.fontWeight = "bold";
|
||||
bassText.innerHTML = (`Bass (${bassFilter.gain.value})`);
|
||||
trebleText.innerHTML = (`Treble (${trebleFilter.gain.value})`);
|
||||
gainText.innerHTML = (`Gain (${AMEx.result.gain.gain.value})`);
|
||||
|
||||
|
||||
let bassAdjust = document.createElement("input");
|
||||
bassAdjust.style.width = "100%";
|
||||
bassAdjust.type = "range";
|
||||
bassAdjust.min = -10;
|
||||
bassAdjust.max = 10;
|
||||
bassAdjust.value = bassFilter.gain.value;
|
||||
bassAdjust.addEventListener("input", function () {
|
||||
bassFilter.gain.value = this.value;
|
||||
bassText.innerHTML = `Bass (${bassFilter.gain.value})`;
|
||||
});
|
||||
|
||||
let trebleAdjust = document.createElement("input");
|
||||
trebleAdjust.style.width = "100%";
|
||||
trebleAdjust.min = -10;
|
||||
trebleAdjust.max = 10;
|
||||
trebleAdjust.type = "range";
|
||||
trebleAdjust.value = trebleFilter.gain.value;
|
||||
trebleAdjust.addEventListener("input", function () {
|
||||
trebleFilter.gain.value = this.value;
|
||||
trebleText.innerHTML = `Treble (${trebleFilter.gain.value})`;
|
||||
});
|
||||
|
||||
let gainAdjust = document.createElement("input");
|
||||
gainAdjust.style.width = "100%";
|
||||
gainAdjust.min = -1;
|
||||
gainAdjust.max = 1;
|
||||
gainAdjust.type = "range";
|
||||
gainAdjust.value = AMEx.result.gain.gain.value;
|
||||
gainAdjust.addEventListener("input", function () {
|
||||
AMEx.result.gain.gain.value = this.value;
|
||||
gainText.innerHTML = `Gain (${AMEx.result.gain.gain.value})`;
|
||||
});
|
||||
|
||||
let bassLabel = document.createElement("label");
|
||||
let trebleLabel = document.createElement("label");
|
||||
let gainLabel = document.createElement("label");
|
||||
|
||||
bassLabel.appendChild(bassText);
|
||||
trebleLabel.appendChild(trebleText);
|
||||
gainLabel.appendChild(gainText);
|
||||
|
||||
bassLabel.appendChild(bassAdjust);
|
||||
bassLabel.appendChild(document.createElement("br"));
|
||||
trebleLabel.appendChild(trebleAdjust);
|
||||
trebleLabel.appendChild(document.createElement("br"));
|
||||
gainLabel.appendChild(gainAdjust);
|
||||
|
||||
win.appendChild(titleText);
|
||||
win.appendChild(bassLabel);
|
||||
win.appendChild(trebleLabel);
|
||||
win.appendChild(gainLabel);
|
||||
win.appendChild(closeBtn);
|
||||
|
||||
backdrop.appendChild(win);
|
||||
document.body.appendChild(backdrop);
|
||||
},
|
||||
getAudioDevices: function(){
|
||||
ipcRenderer.send('getAudioDevices','');
|
||||
},
|
||||
startExclusiveAudio: async function(id){
|
||||
|
||||
if(AMEx.result.source != null || MVsource != null){
|
||||
if(EAoutputID!= id){
|
||||
EAoutputID = id;
|
||||
EAOverride = false;
|
||||
ipcRenderer.send('muteAudio',true);
|
||||
ipcRenderer.send('enableExclusiveAudio',id);
|
||||
windowAudioNode = AMEx.context.createGain();
|
||||
try{
|
||||
AMEx.result.source.connect(windowAudioNode);}
|
||||
catch(e){}
|
||||
|
||||
var options = {
|
||||
mimeType : 'audio/wav'
|
||||
};
|
||||
var destnode = AMEx.context.createMediaStreamDestination();
|
||||
windowAudioNode.connect(destnode);
|
||||
if(!recorder){
|
||||
recorder = new MediaRecorder(destnode.stream,options,workerOptions);
|
||||
recorder.start(1);
|
||||
|
||||
recorder.ondataavailable = function(e) {
|
||||
e.data.arrayBuffer().then(buffer => {
|
||||
if(!GCOverride) {ipcRenderer.send('writeWAV',buffer,preferences.audio.castingBitDepth);}
|
||||
if(!EAOverride) {ipcRenderer.send('writePCM',buffer);}
|
||||
}
|
||||
);
|
||||
}}
|
||||
|
||||
} else {console.log('device already in exclusive mode');}
|
||||
} else {
|
||||
outputID = id;
|
||||
queueExclusive = true;
|
||||
}
|
||||
},
|
||||
stopExclusiveAudio: function(){
|
||||
try{
|
||||
recorder.stop();
|
||||
recorder = null;
|
||||
} catch(e){}
|
||||
EAOverride = true;
|
||||
EAoutputID = -1;
|
||||
outputID = -1;
|
||||
queueExclusive = false;
|
||||
ipcRenderer.send('muteAudio',false);
|
||||
ipcRenderer.send('disableExclusiveAudio','');
|
||||
|
||||
},
|
||||
getGCDevices: function(){
|
||||
ipcRenderer.send('getChromeCastDevices','');
|
||||
},
|
||||
playGC : async function(device){
|
||||
console.log('wot');
|
||||
AudioOutputs.activeCasts.push(device);
|
||||
GCOverride = false;
|
||||
if(AMEx.result.source != null || MVsource != null ){
|
||||
queueChromecast = false;
|
||||
const musicType = (MusicKit.getInstance().nowPlayingItem != null) ? MusicKit.getInstance().nowPlayingItem["type"] ?? '' : '';
|
||||
const trackName = ((MusicKit.getInstance().nowPlayingItem != null) ? MusicKit.getInstance().nowPlayingItem.title ?? '' : '');
|
||||
const artistName = ((MusicKit.getInstance().nowPlayingItem != null) ? MusicKit.getInstance().nowPlayingItem.artistName ?? '' : '');
|
||||
const albumName = ((MusicKit.getInstance().nowPlayingItem != null) ? MusicKit.getInstance().nowPlayingItem.albumName ?? '' : '');
|
||||
ipcRenderer.send('performGCCast',device, "Apple Music Electron","Playing ...","3.0.0 beta",'');
|
||||
windowAudioNode = AMEx.context.createGain();
|
||||
try{
|
||||
AMEx.result.source.connect(windowAudioNode);}
|
||||
catch(e){}
|
||||
|
||||
var options = {
|
||||
mimeType : 'audio/wav'
|
||||
};
|
||||
var destnode = AMEx.context.createMediaStreamDestination();
|
||||
windowAudioNode.connect(destnode);
|
||||
if(!recorder){
|
||||
recorder = new MediaRecorder(destnode.stream,options,workerOptions);
|
||||
recorder.start(1);
|
||||
|
||||
recorder.ondataavailable = function(e) {
|
||||
e.data.arrayBuffer().then(buffer => {
|
||||
if(!GCOverride) {
|
||||
ipcRenderer.send('writeWAV',buffer,preferences.audio.castingBitDepth);
|
||||
}
|
||||
if(!EAOverride) {ipcRenderer.send('writePCM',buffer);}
|
||||
}
|
||||
);
|
||||
}}
|
||||
|
||||
} else {queueChromecast = true; selectedGC = device}
|
||||
|
||||
|
||||
},
|
||||
stopGC : function(){
|
||||
queueChromecast = false;
|
||||
try{
|
||||
recorder.stop();
|
||||
recorder = null;
|
||||
} catch(e){}
|
||||
GCOverride = true;
|
||||
this.activeCasts = [];
|
||||
ipcRenderer.send('stopGCast','');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
switch (String.fromCharCode(event.which).toLowerCase()) {
|
||||
case "2":
|
||||
if (document.getElementById('eq-menu')){
|
||||
document.getElementById('eq-menu').parentNode.getElementsByTagName('button')[0].click();
|
||||
}
|
||||
else{AudioOutputs.ShowEQ();}
|
||||
break;
|
||||
case "3":
|
||||
(EAOverride) ? (EAOverride = false) : (EAOverride = true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function waitFor(condition, callback) {
|
||||
if(condition() == null || !condition() ) {
|
||||
window.setTimeout(waitFor.bind(null, condition, callback), 1000);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function setIntervalX(callback, delay, repetitions) {
|
||||
var x = 0;
|
||||
var intervalID = window.setInterval(function () {
|
||||
|
||||
callback();
|
||||
|
||||
if (++x === repetitions) {
|
||||
window.clearInterval(intervalID);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
AudioOutputs.init()
|
Loading…
Add table
Add a link
Reference in a new issue