4
0
mirror of https://github.com/AzuraCast/AzuraCast.git synced 2024-06-13 20:56:36 +00:00

Merge commit 'd6384eefca8b5d540314e584166e2647034f9127'

This commit is contained in:
Buster Neece 2023-01-01 09:23:49 -06:00
commit 57defbb0e2
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
21 changed files with 869 additions and 6640 deletions

View File

@ -1,2 +1 @@
vue/components/Public/WebDJ/*
vue/vendor/chartjs-colorschemes/* vue/vendor/chartjs-colorschemes/*

View File

@ -2,7 +2,7 @@
import gulp from 'gulp'; import gulp from 'gulp';
import babel from 'gulp-babel'; import babel from 'gulp-babel';
import { deleteAsync as del } from 'del'; import {deleteAsync as del} from 'del';
import rev from 'gulp-rev'; import rev from 'gulp-rev';
import concat from 'gulp-concat'; import concat from 'gulp-concat';
import uglify from 'gulp-uglify'; import uglify from 'gulp-uglify';
@ -66,7 +66,7 @@ const jsFiles = {
files: [ files: [
'js/webcaster/*.js' 'js/webcaster/*.js'
] ]
}, }
}; };
const defaultTasks = Object.keys(jsFiles); const defaultTasks = Object.keys(jsFiles);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,446 +0,0 @@
// Generated by CoffeeScript 1.11.1
(function() {
var AudioContext, Webcast;
Webcast = {
Encoder: {}
};
if (typeof window !== "undefined") {
window.Webcast = Webcast;
}
if (typeof self !== "undefined") {
self.Webcast = Webcast;
}
Webcast.Encoder.Asynchronous = (function() {
function Asynchronous(arg) {
var blob, j, len1, script, scripts;
this.encoder = arg.encoder, scripts = arg.scripts;
this.mime = this.encoder.mime;
this.info = this.encoder.info;
this.channels = this.encoder.channels;
this.pending = [];
this.scripts = [];
for (j = 0, len1 = scripts.length; j < len1; j++) {
script = scripts[j];
this.scripts.push("'" + script + "'");
}
script = "var window;\nimportScripts(" + (this.scripts.join()) + ");\nvar encoder = " + (this.encoder.toString()) + ";\nself.onmessage = function (e) {\n var type = e.data.type;\n var data = e.data.data;\n if (type === \"buffer\") {\n encoder.encode(data, function (encoded) {\n postMessage(encoded);\n });\n return;\n }\n if (type === \"close\") {\n encoder.close(function (buffer) {\n postMessage({close:true, buffer:buffer});\n self.close();\n });\n return;\n }\n};";
blob = new Blob([script], {
type: "text/javascript"
});
this.worker = new Worker(URL.createObjectURL(blob));
this.worker.onmessage = (function(_this) {
return function(arg1) {
var data;
data = arg1.data;
return _this.pending.push(data);
};
})(this);
}
Asynchronous.prototype.toString = function() {
return "(new Webcast.Encoder.Asynchronous({\n encoder: " + (this.encoder.toString()) + ",\n scripts: [" + (this.scripts.join()) + "]\n}))";
};
Asynchronous.prototype.close = function(fn) {
this.worker.onmessage = (function(_this) {
return function(arg) {
var chunk, data, j, k, len, len1, len2, offset, ref, ref1, ret;
data = arg.data;
if (!data.close) {
_this.pending.push(data);
return;
}
_this.pending.push(data.buffer);
len = 0;
ref = _this.pending;
for (j = 0, len1 = ref.length; j < len1; j++) {
chunk = ref[j];
len += chunk.length;
}
ret = new Uint8Array(len);
offset = 0;
ref1 = _this.pending;
for (k = 0, len2 = ref1.length; k < len2; k++) {
chunk = ref1[k];
ret.set(chunk, offset);
offset += chunk.length;
}
return fn(ret);
};
})(this);
return this.worker.postMessage({
type: "close"
});
};
Asynchronous.prototype.encode = function(buffer, fn) {
this.worker.postMessage({
type: "buffer",
data: buffer
});
return fn(this.pending.shift());
};
return Asynchronous;
})();
if (typeof window !== "undefined") {
AudioContext = window.AudioContext || window.webkitAudioContext;
AudioContext.prototype.createWebcastSource = function(bufferSize, channels, passThrough) {
var context, node, options;
context = this;
node = context.createScriptProcessor(bufferSize, channels, channels);
passThrough || (passThrough = false);
options = {
recorderSource: null,
encoder: null,
socket: null,
passThrough: passThrough || false
};
node.onaudioprocess = function(buf) {
var audio, channel, channelData, j, ref, ref1;
audio = [];
for (channel = j = 0, ref = buf.inputBuffer.numberOfChannels - 1; 0 <= ref ? j <= ref : j >= ref; channel = 0 <= ref ? ++j : --j) {
channelData = buf.inputBuffer.getChannelData(channel);
audio[channel] = channelData;
if (options.passThrough) {
buf.outputBuffer.getChannelData(channel).set(channelData);
} else {
buf.outputBuffer.getChannelData(channel).set(new Float32Array(channelData.length));
}
}
return (ref1 = options.encoder) != null ? typeof ref1.encode === "function" ? ref1.encode(audio, function(data) {
var ref2;
if (data != null) {
return (ref2 = options.socket) != null ? ref2.sendData(data) : void 0;
}
}) : void 0 : void 0;
};
node.setPassThrough = function(b) {
return options.passThrough = b;
};
node.connectSocket = function(encoder, url) {
if (encoder instanceof Webcast.Recorder) {
options.recorderSource = context.createMediaStreamDestination();
node.connect(options.recorderSource);
encoder.start(options.recoderSource.stream, function(data) {
var ref;
if (data != null) {
return (ref = options.socket) != null ? ref.sendData(data) : void 0;
}
});
}
options.encoder = encoder;
return options.socket = new Webcast.Socket({
url: url,
mime: options.encoder.mime,
info: options.encoder.info
});
};
node.close = function(cb) {
var fn, ref, ref1;
if ((ref = options.recorderSource) != null) {
ref.disconnect();
}
options.recorderSource = null;
fn = function() {
var ref1;
if ((ref1 = options.socket) != null) {
ref1.close();
}
options.socket = options.encoder = null;
return typeof cb === "function" ? cb() : void 0;
};
if (((ref1 = options.encoder) != null ? ref1.close : void 0) == null) {
return fn();
}
return options.encoder.close(function(data) {
var ref2;
if ((ref2 = options.socket) != null) {
ref2.sendData(data);
}
return fn();
});
};
node.getSocket = function() {
return options.socket;
};
node.sendMetadata = (function(_this) {
return function(metadata) {
var ref;
return (ref = options.socket) != null ? ref.sendMetadata(metadata) : void 0;
};
})(this);
node.isOpen = function() {
return options != null ? options.socket.isOpen() : void 0;
};
return node;
};
}
Webcast.Encoder.Mp3 = (function() {
Mp3.prototype.mime = "audio/mpeg";
function Mp3(arg) {
this.samplerate = arg.samplerate, this.bitrate = arg.bitrate, this.channels = arg.channels;
this.shine = new Shine({
samplerate: this.samplerate,
bitrate: this.bitrate,
channels: this.channels,
mode: this.channels === 1 ? Shine.MONO : Shine.JOINT_STEREO
});
this.info = {
audio: {
channels: this.channels,
samplerate: this.samplerate,
bitrate: this.bitrate,
encoder: "libshine"
}
};
this;
}
Mp3.prototype.toString = function() {
return "(new Webcast.Encoder.Mp3({\n bitrate: " + this.bitrate + ",\n channels: " + this.channels + ",\n samplerate: " + this.samplerate + "\n }))";
};
Mp3.prototype.close = function(data, fn) {
var flushed, rem;
rem = new Uint8Array;
if (fn != null) {
if ((data != null ? data.length : void 0) > 0) {
rem = this.shine.encode(data);
}
} else {
fn = data;
}
flushed = this.shine.close();
data = new Uint8Array(rem.length + flushed.length);
data.set(rem);
data.set(flushed, rem.length);
return fn(data);
};
Mp3.prototype.encode = function(data, fn) {
data = data.slice(0, this.channels);
return fn(this.shine.encode(data));
};
return Mp3;
})();
Webcast.Encoder.Raw = (function() {
function Raw(arg) {
this.channels = arg.channels, this.samplerate = arg.samplerate;
this.mime = "audio/x-raw,format=S8,channels=" + this.channels + ",layout=interleaved,rate=" + this.samplerate;
this.info = {
audio: {
channels: this.channels,
samplerate: this.samplerate,
encoder: "RAW u8 encoder"
}
};
}
Raw.prototype.toString = function() {
return "(new Webcast.Encoder.Raw({\n channels: " + this.channels + ",\n samplerate: " + this.samplerate + "\n }))";
};
Raw.prototype.doEncode = function(data) {
var buf, chan, channels, i, j, k, ref, ref1, samples;
channels = data.length;
samples = data[0].length;
buf = new Int8Array(channels * samples);
for (chan = j = 0, ref = channels - 1; 0 <= ref ? j <= ref : j >= ref; chan = 0 <= ref ? ++j : --j) {
for (i = k = 0, ref1 = samples - 1; 0 <= ref1 ? k <= ref1 : k >= ref1; i = 0 <= ref1 ? ++k : --k) {
buf[channels * i + chan] = data[chan][i] * 127;
}
}
return buf;
};
Raw.prototype.close = function(data, fn) {
var ret;
ret = new Uint8Array;
if (fn != null) {
if ((data != null ? data.count : void 0) > 0) {
ret = this.doEncode(data);
}
} else {
fn = data;
}
return fn(ret);
};
Raw.prototype.encode = function(data, fn) {
return fn(this.doEncode(data));
};
return Raw;
})();
Webcast.Recorder = (function() {
Recorder.prototype.mime = "audio/ogg";
function Recorder(arg) {
this.samplerate = arg.samplerate, this.bitrate = arg.bitrate, this.channels = arg.channels;
this.info = {
audio: {
channels: this.channels,
samplerate: this.samplerate,
bitrate: this.bitrate,
encoder: "MediaRecorder"
}
};
}
Recorder.prototype.start = function(stream, cb) {
var recorder;
recorder = new MediaRecorder(stream);
return recorder.ondataavailable = (function(_this) {
return function(e) {
var blob;
if (recorder.state === "recording") {
blob = new Blob([e.data], _this.mime);
return cb(blob);
}
};
})(this);
};
return Recorder;
})();
Webcast.Encoder.Resample = (function() {
function Resample(arg) {
var i, j, ref;
this.encoder = arg.encoder, this.samplerate = arg.samplerate, this.type = arg.type;
this.mime = this.encoder.mime;
this.info = this.encoder.info;
this.channels = this.encoder.channels;
this.ratio = parseFloat(this.encoder.samplerate) / parseFloat(this.samplerate);
this.type = this.type || Samplerate.FASTEST;
this.resamplers = [];
this.remaining = [];
for (i = j = 0, ref = this.channels - 1; 0 <= ref ? j <= ref : j >= ref; i = 0 <= ref ? ++j : --j) {
this.resamplers[i] = new Samplerate({
type: this.type
});
this.remaining[i] = new Float32Array;
}
}
Resample.prototype.toString = function() {
return "(new Webcast.Encoder.Resample({\n encoder: " + (this.encoder.toString()) + ",\n samplerate: " + this.samplerate + ",\n type: " + this.type + "\n }))";
};
Resample.prototype.close = function(fn) {
var data, i, j, ref;
for (i = j = 0, ref = this.remaining.length - 1; 0 <= ref ? j <= ref : j >= ref; i = 0 <= ref ? ++j : --j) {
data = this.resamplers[i].process({
data: this.remaining[i],
ratio: this.ratio,
last: true
}).data;
}
return this.encoder.close(data, fn);
};
Resample.prototype.concat = function(a, b) {
var ret;
if (typeof b === "undefined") {
return a;
}
ret = new Float32Array(a.length + b.length);
ret.set(a);
ret.subarray(a.length).set(b);
return ret;
};
Resample.prototype.encode = function(buffer, fn) {
var data, i, j, ref, ref1, used;
for (i = j = 0, ref = this.channels - 1; 0 <= ref ? j <= ref : j >= ref; i = 0 <= ref ? ++j : --j) {
buffer[i] = this.concat(this.remaining[i], buffer[i]);
ref1 = this.resamplers[i].process({
data: buffer[i],
ratio: this.ratio
}), data = ref1.data, used = ref1.used;
this.remaining[i] = buffer[i].subarray(used);
buffer[i] = data;
}
return this.encoder.encode(buffer, fn);
};
return Resample;
})();
Webcast.Socket = function(arg) {
var hello, info, key, mime, parser, password, send, socket, url, user, value;
url = arg.url, mime = arg.mime, info = arg.info;
parser = document.createElement("a");
parser.href = url;
user = parser.username;
password = parser.password;
parser.username = parser.password = "";
url = parser.href;
socket = new WebSocket(url, "webcast");
socket.mime = mime;
socket.info = info;
hello = {
mime: mime
};
if ((user != null) && user !== "") {
hello.user = socket.user = user;
}
if ((password != null) && password !== "") {
hello.password = socket.password = password;
}
for (key in info) {
value = info[key];
hello[key] = value;
}
send = socket.send;
socket.send = null;
socket.addEventListener("open", function() {
return send.call(socket, JSON.stringify({
type: "hello",
data: hello
}));
});
socket.sendData = function(data) {
if (!socket.isOpen()) {
return;
}
if (!((data != null ? data.length : void 0) > 0)) {
return;
}
if (!(data instanceof ArrayBuffer)) {
data = data.buffer.slice(data.byteOffset, data.length * data.BYTES_PER_ELEMENT);
}
return send.call(socket, data);
};
socket.sendMetadata = function(metadata) {
if (!socket.isOpen()) {
return;
}
return send.call(socket, JSON.stringify({
type: "metadata",
data: metadata
}));
};
socket.isOpen = function() {
return socket.readyState === WebSocket.OPEN;
};
return socket;
};
}).call(this);

View File

@ -7,7 +7,7 @@
<div class="container pt-5"> <div class="container pt-5">
<div class="form-row"> <div class="form-row">
<div class="col-md-4 mb-sm-4"> <div class="col-md-4 mb-sm-4">
<settings-panel v-bind="{ stationName, baseUri, libUrls }" /> <settings-panel :station-name="stationName" />
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
@ -36,57 +36,33 @@
</section> </section>
</template> </template>
<script> <script setup>
import MixerPanel from './WebDJ/MixerPanel.vue'; import MixerPanel from './WebDJ/MixerPanel.vue';
import MicrophonePanel from './WebDJ/MicrophonePanel.vue'; import MicrophonePanel from './WebDJ/MicrophonePanel.vue';
import PlaylistPanel from './WebDJ/PlaylistPanel.vue'; import PlaylistPanel from './WebDJ/PlaylistPanel.vue';
import SettingsPanel from './WebDJ/SettingsPanel.vue'; import SettingsPanel from './WebDJ/SettingsPanel.vue';
import {useProvideWebDjNode, useWebDjNode} from "~/components/Public/WebDJ/useWebDjNode";
import {ref} from "vue";
import {useWebcaster, webcasterProps} from "~/components/Public/WebDJ/useWebcaster";
import {useProvideMixer} from "~/components/Public/WebDJ/useMixerValue";
import {useProvidePassthroughSync} from "~/components/Public/WebDJ/usePassthroughSync";
import Stream from './WebDJ/Stream.js'; const props = defineProps({
...webcasterProps,
stationName: {
type: String,
required: true
},
});
export default { const webcaster = useWebcaster(props);
components: {
MixerPanel,
MicrophonePanel,
PlaylistPanel,
SettingsPanel
},
provide: function () {
return {
getStream: this.getStream,
resumeStream: this.resumeStream
};
},
props: {
stationName: {
type: String,
required: true
},
libUrls: {
type: Array,
default: () => {
return [];
}
},
baseUri: {
type: String,
required: true
}
},
data: function () {
return {
'stream': Stream
};
},
methods: {
getStream: function () {
this.stream.init();
return this.stream; const node = useWebDjNode(webcaster);
}, useProvideWebDjNode(node);
resumeStream: function () {
this.stream.resumeContext(); const mixer = ref(1.0);
} useProvideMixer(mixer);
}
}; const passthroughSync = ref('');
useProvidePassthroughSync(passthroughSync);
</script> </script>

View File

@ -8,7 +8,7 @@
</h5> </h5>
</div> </div>
<div class="flex-shrink-0 pl-3"> <div class="flex-shrink-0 pl-3">
<volume-slider v-model.number="volume" /> <volume-slider v-model.number="trackGain" />
</div> </div>
</div> </div>
</div> </div>
@ -20,15 +20,15 @@
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button <button
class="btn btn-danger" class="btn btn-danger"
:class="{ active: playing }" :class="{ active: isPlaying }"
@click="toggleRecording" @click="togglePlaying"
> >
<icon icon="mic" /> <icon icon="mic" />
</button> </button>
<button <button
class="btn" class="btn"
:class="{ 'btn-primary': passThrough }" :class="{ 'btn-primary': trackPassThrough }"
@click="cue" @click="trackPassThrough = !trackPassThrough"
> >
{{ $gettext('Cue') }} {{ $gettext('Cue') }}
</button> </button>
@ -50,7 +50,8 @@
class="form-control" class="form-control"
> >
<option <option
v-for="device_row in devices" v-for="device_row in audioInputs"
:key="device_row.deviceId"
:value="device_row.deviceId" :value="device_row.deviceId"
> >
{{ device_row.label }} {{ device_row.label }}
@ -62,138 +63,83 @@
</div> </div>
<div <div
v-if="playing" v-if="isPlaying"
class="mt-3" class="mt-3"
> >
<div class="progress mb-1">
<div
class="progress-bar"
:style="{ width: volumeLeft+'%' }"
/>
</div>
<div class="progress mb-2"> <div class="progress mb-2">
<div <div
class="progress-bar" class="progress-bar"
:style="{ width: volumeRight+'%' }" :style="{ width: volume+'%' }"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script>
import track from './Track.js'; <script setup>
import {first, filter, isEmpty} from 'lodash';
import Icon from '~/components/Common/Icon'; import Icon from '~/components/Common/Icon';
import VolumeSlider from "~/components/Public/WebDJ/VolumeSlider"; import VolumeSlider from "~/components/Public/WebDJ/VolumeSlider";
import {useDevicesList} from "@vueuse/core";
import {ref, watch} from "vue";
import {useWebDjTrack} from "~/components/Public/WebDJ/useWebDjTrack";
import {usePassthroughSync} from "~/components/Public/WebDJ/usePassthroughSync";
export default { const {
components: {VolumeSlider, Icon}, createMicrophoneSource,
extends: track, source,
isPlaying,
trackGain,
trackPassThrough,
volume,
prepare,
stop
} = useWebDjTrack();
data: function () { usePassthroughSync(trackPassThrough, 'microphone');
return {
'device': null,
'devices': [],
'isRecording': false
};
},
watch: {
device: function () {
if (this.source == null) {
return;
}
return this.createSource();
}
},
mounted: function () {
let base, base1;
// Get multimedia devices by requesting them from the browser. const {audioInputs} = useDevicesList({
navigator.mediaDevices || (navigator.mediaDevices = {}); requestPermissions: true,
constraints: {audio: true, video: false}
});
(base = navigator.mediaDevices).getUserMedia || (base.getUserMedia = function (constraints) { const device = ref(null);
let fn; watch(audioInputs, (inputs) => {
fn = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; if (device.value === null) {
if (fn == null) { device.value = inputs[0]?.deviceId;
return Promise.reject(new Error('getUserMedia is not implemented in this browser'));
}
return new Promise(function (resolve, reject) {
return fn.call(navigator, constraints, resolve, reject);
});
});
(base1 = navigator.mediaDevices).enumerateDevices || (base1.enumerateDevices = function () {
return Promise.reject(new Error('enumerateDevices is not implemented on this browser'));
});
const vm_mic = this;
navigator.mediaDevices.getUserMedia({
audio: true,
video: false
}).then(function () {
return navigator.mediaDevices.enumerateDevices().then(vm_mic.setDevices);
});
this.$root.$on('new-cue', this.onNewCue);
},
methods: {
cue: function () {
this.resumeStream();
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : 'microphone');
},
onNewCue: function (new_cue) {
this.passThrough = (new_cue === 'microphone');
},
toggleRecording: function () {
this.resumeStream();
if (this.playing) {
this.stop();
} else {
this.play();
}
},
createSource: function (cb) {
let constraints;
if (this.source != null) {
this.source.disconnect(this.destination);
}
constraints = {
video: false
};
if (this.device) {
constraints.audio = {
deviceId: this.device
};
} else {
constraints.audio = true;
}
return this.getStream().createMicrophoneSource(constraints, (source) => {
this.source = source;
this.source.connect(this.destination);
return typeof cb === 'function' ? cb() : void 0;
});
},
play: function () {
this.prepare();
return this.createSource(() => {
this.playing = true;
this.paused = false;
});
},
setDevices: function (devices) {
devices = filter(devices, function ({kind}) {
return kind === 'audioinput';
});
if (isEmpty(devices)) {
return;
}
this.devices = devices;
this.device = first(devices).deviceId;
}
} }
});
let destination = null;
const createSource = () => {
if (source.value != null) {
source.value.disconnect(destination);
}
createMicrophoneSource(device.value, (newSource) => {
source.value = newSource;
newSource.connect(destination);
});
}; };
watch(device, () => {
if (source.value === null || destination === null) {
return;
}
createSource();
});
const play = () => {
destination = prepare();
createSource();
}
const togglePlaying = () => {
if (isPlaying.value) {
stop();
} else {
play();
}
}
</script> </script>

View File

@ -14,14 +14,14 @@
</div> </div>
<div class="flex-fill px-2"> <div class="flex-fill px-2">
<input <input
v-model="position" v-model.number="mixer"
type="range" type="range"
min="0" min="0"
max="1" max="2"
step="0.01" step="0.05"
class="custom-range slider" class="custom-range slider"
style="width: 200px; height: 10px;" style="width: 200px; height: 10px;"
@click.right.prevent="position = 0.5" @click.right.prevent="mixer = 1.0"
> >
</div> </div>
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@ -34,17 +34,8 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { import {useInjectMixer} from "~/components/Public/WebDJ/useMixerValue";
data () {
return { const mixer = useInjectMixer();
'position': 0.5
};
},
watch: {
position(val) {
this.$root.$emit('new-mixer-value', val);
}
}
};
</script> </script>

View File

@ -4,11 +4,11 @@
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="flex-fill text-nowrap"> <div class="flex-fill text-nowrap">
<h5 class="card-title"> <h5 class="card-title">
{{ lang_header }} {{ langHeader }}
</h5> </h5>
</div> </div>
<div class="flex-shrink-0 pl-3"> <div class="flex-shrink-0 pl-3">
<volume-slider v-model.number="volume" /> <volume-slider v-model.number="localGain" />
</div> </div>
</div> </div>
</div> </div>
@ -16,14 +16,14 @@
<div class="control-group d-flex justify-content-center"> <div class="control-group d-flex justify-content-center">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button <button
v-if="!playing || paused" v-if="!isPlaying || isPaused"
class="btn btn-sm btn-success" class="btn btn-sm btn-success"
@click="play" @click="play"
> >
<icon icon="play_arrow" /> <icon icon="play_arrow" />
</button> </button>
<button <button
v-if="playing && !paused" v-if="isPlaying && !isPaused"
class="btn btn-sm btn-warning" class="btn btn-sm btn-warning"
@click="togglePause()" @click="togglePause()"
> >
@ -49,8 +49,8 @@
</button> </button>
<button <button
class="btn btn-sm" class="btn btn-sm"
:class="{ 'btn-primary': passThrough }" :class="{ 'btn-primary': trackPassThrough }"
@click="cue()" @click="trackPassThrough = !trackPassThrough"
> >
{{ $gettext('Cue') }} {{ $gettext('Cue') }}
</button> </button>
@ -58,7 +58,7 @@
</div> </div>
<div <div
v-if="playing" v-if="isPlaying"
class="mt-3" class="mt-3"
> >
<div class="d-flex flex-row mb-2"> <div class="d-flex flex-row mb-2">
@ -67,14 +67,13 @@
</div> </div>
<div class="flex-fill"> <div class="flex-fill">
<input <input
v-model="seekingPosition"
type="range" type="range"
min="0" min="0"
max="100" max="100"
step="0.1" step="0.1"
class="custom-range slider" class="custom-range slider"
:value="seekingPosition"
@mousedown="isSeeking = true" @mousedown="isSeeking = true"
@mousemove="doSeek($event)"
@mouseup="isSeeking = false" @mouseup="isSeeking = false"
> >
</div> </div>
@ -83,16 +82,10 @@
</div> </div>
</div> </div>
<div class="progress mb-1">
<div
class="progress-bar"
:style="{ width: volumeLeft+'%' }"
/>
</div>
<div class="progress"> <div class="progress">
<div <div
class="progress-bar" class="progress-bar"
:style="{ width: volumeRight+'%' }" :style="{ width: volume+'%' }"
/> />
</div> </div>
</div> </div>
@ -156,6 +149,7 @@
> >
<a <a
v-for="(rowFile, rowIndex) in files" v-for="(rowFile, rowIndex) in files"
:key="rowFile.file.name"
href="#" href="#"
class="list-group-item list-group-item-action flex-column align-items-start" class="list-group-item list-group-item-action flex-column align-items-start"
:class="{ active: rowIndex === fileIndex }" :class="{ active: rowIndex === fileIndex }"
@ -163,199 +157,204 @@
> >
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
<h5 class="mb-0">{{ <h5 class="mb-0">{{
rowFile?.metadata?.title ?? $gettext('Unknown Title') rowFile.metadata?.title ?? $gettext('Unknown Title')
}}</h5> }}</h5>
<small class="pt-1">{{ formatTime(rowFile.audio.length) }}</small> <small class="pt-1">{{ formatTime(rowFile.audio.length) }}</small>
</div> </div>
<p class="mb-0">{{ rowFile?.metadata?.artist ?? $gettext('Unknown Artist') }}</p> <p class="mb-0">{{ rowFile.metadata?.artist ?? $gettext('Unknown Artist') }}</p>
</a> </a>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
import track from './Track.js';
import {forEach} from 'lodash';
import Icon from '~/components/Common/Icon'; import Icon from '~/components/Common/Icon';
import VolumeSlider from "~/components/Public/WebDJ/VolumeSlider"; import VolumeSlider from "~/components/Public/WebDJ/VolumeSlider";
import formatTime from "../../../functions/formatTime"; import formatTime from "~/functions/formatTime";
import {computed, ref, watch} from "vue";
import {useWebDjTrack} from "~/components/Public/WebDJ/useWebDjTrack";
import {useTranslate} from "~/vendor/gettext";
import {forEach} from "lodash";
import {useInjectMixer} from "~/components/Public/WebDJ/useMixerValue";
import {usePassthroughSync} from "~/components/Public/WebDJ/usePassthroughSync";
export default { const props = defineProps({
components: {VolumeSlider, Icon}, id: {
extends: track, type: String,
props: { required: true
id: { }
type: String, });
required: true
const isLeftPlaylist = computed(() => {
return props.id === 'playlist_1';
});
const {
createAudioSource,
sendMetadata,
source,
isPlaying,
isPaused,
trackGain,
trackPassThrough,
position,
volume,
prepare,
togglePause,
stop
} = useWebDjTrack();
usePassthroughSync(trackPassThrough, props.id);
const fileIndex = ref(-1);
const files = ref([]);
const duration = ref(0.0);
const loop = ref(false);
const playThrough = ref(false);
const isSeeking = ref(false);
const seekingPosition = computed({
get: () => {
return (100.0 * position.value / parseFloat(duration.value));
},
set: (val) => {
if (!isSeeking.value || !source.value) {
return;
} }
},
data() {
return {
'fileIndex': -1,
'files': [],
'volume': 100, source.value.seek(val / 100);
'duration': 0.0, }
'playThrough': true, });
'loop': false,
'isSeeking': false, // Factor in mixer and local gain to calculate total gain.
'seekPosition': 0, const localGain = ref(55);
'mixGainObj': null const mixer = useInjectMixer();
};
}, const computedGain = computed(() => {
computed: { let multiplier;
lang_header () { if (isLeftPlaylist.value) {
return (this.id === 'playlist_1') multiplier = (mixer.value > 1)
? this.$gettext('Playlist 1') ? 2.0 - (mixer.value)
: this.$gettext('Playlist 2'); : 1.0;
}, } else {
positionPercent () { multiplier = (mixer.value < 1)
return (100.0 * this.position / parseFloat(this.duration)); ? mixer.value
}, : 1.0;
seekingPosition () { }
return (this.isSeeking) ? this.seekPosition : this.positionPercent;
return localGain.value * multiplier;
});
watch(computedGain, (newGain) => {
trackGain.value = newGain;
}, {immediate: true});
const {$gettext} = useTranslate();
const langHeader = computed(() => {
return isLeftPlaylist.value
? $gettext('Playlist 1')
: $gettext('Playlist 2');
});
const addNewFiles = (newFiles) => {
forEach(newFiles, (file) => {
file.readTaglibMetadata((data) => {
files.value.push({
file: file,
audio: data.audio,
metadata: data.metadata || {title: '', artist: ''}
});
});
});
};
const selectFile = (options = {}) => {
if (files.value.length === 0) {
return;
}
if (options.fileIndex) {
fileIndex.value = options.fileIndex;
} else {
fileIndex.value += options.backward ? -1 : 1;
if (fileIndex.value < 0) {
fileIndex.value = files.value.length - 1;
} }
},
mounted () {
this.mixGainObj = this.getStream().context.createGain();
this.mixGainObj.connect(this.getStream().webcast);
this.sink = this.mixGainObj;
this.$root.$on('new-mixer-value', this.setMixGain); if (fileIndex.value >= files.value.length) {
this.$root.$on('new-cue', this.onNewCue); if (options.isAutoPlay && !loop.value) {
}, fileIndex.value = -1;
methods: { return;
formatTime, }
cue() {
this.resumeStream(); if (fileIndex.value < 0) {
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : this.id); fileIndex.value = files.value.length - 1;
},
onNewCue(new_cue) {
this.passThrough = (new_cue === this.id);
},
setMixGain(new_value) {
if (this.id === 'playlist_1') {
this.mixGainObj.gain.value = 1.0 - new_value;
} else { } else {
this.mixGainObj.gain.value = new_value; fileIndex.value = 0;
}
},
addNewFiles (newFiles) {
forEach(newFiles, (file) => {
file.readTaglibMetadata((data) => {
this.files.push({
file: file,
audio: data.audio,
metadata: data.metadata || {title: '', artist: ''}
});
});
});
},
play (options) {
this.resumeStream();
if (this.paused) {
this.togglePause();
return;
}
this.stop();
if (!(this.file = this.selectFile(options))) {
return;
}
this.prepare();
return this.getStream().createFileSource(this.file, this, (source) => {
let ref1;
this.source = source;
this.source.connect(this.destination);
if (this.source.duration != null) {
this.duration = this.source.duration();
} else {
if (((ref1 = this.file.audio) != null ? ref1.length : void 0) != null) {
this.duration = parseFloat(this.file.audio.length);
}
}
this.source.play(this.file);
this.$root.$emit('metadata-update', {
title: this.file.metadata.title,
artist: this.file.metadata.artist
});
this.playing = true;
this.paused = false;
});
},
selectFile (options = {}) {
if (this.files.length === 0) {
return;
}
if (options.fileIndex) {
this.fileIndex = options.fileIndex;
} else {
this.fileIndex += options.backward ? -1 : 1;
if (this.fileIndex < 0) {
this.fileIndex = this.files.length - 1;
}
if (this.fileIndex >= this.files.length) {
if (options.isAutoPlay && !this.loop) {
this.fileIndex = -1;
return;
}
if (this.fileIndex < 0) {
this.fileIndex = this.files.length - 1;
} else {
this.fileIndex = 0;
}
}
}
return this.files[this.fileIndex];
},
previous () {
if (!this.playing) {
return;
}
return this.play({
backward: true
});
},
next () {
if (!this.playing) {
return;
}
return this.play();
},
onEnd () {
this.stop();
if (this.playThrough) {
return this.play({
isAutoPlay: true
});
}
},
doSeek (e) {
if (this.isSeeking) {
this.seekPosition = e.target.value;
this.seek(this.seekPosition / 100);
} }
} }
} }
return files.value[fileIndex.value];
};
const play = (options = {}) => {
let file = selectFile(options);
if (!file) {
return;
}
if (isPaused.value) {
togglePause();
return;
}
stop();
let destination = prepare();
createAudioSource(file, (newSource) => {
source.value = newSource;
newSource.connect(destination);
if (newSource.duration !== null) {
duration.value = newSource.duration();
} else if (file.audio !== null) {
duration.value = parseFloat(file.audio.length);
}
newSource.play(file);
sendMetadata({
title: file.metadata.title,
artist: file.metadata.artist
});
}, () => {
stop();
if (playThrough.value) {
play({
isAutoPlay: true
});
}
});
};
const previous = () => {
if (!isPlaying.value) {
return;
}
play({backward: true});
};
const next = () => {
if (!isPlaying.value) {
return;
}
play();
}; };
</script> </script>

View File

@ -7,301 +7,117 @@
<small>{{ stationName }}</small> <small>{{ stationName }}</small>
</h5> </h5>
</div> </div>
<div class="card-body pt-0"> <template v-if="isConnected">
<div class="form-row pb-4"> <div class="card-body">
<div class="col-sm-12"> <div class="form-group">
<ul class="nav nav-tabs card-header-tabs mt-0"> <label
<li class="nav-item"> for="metadata_title"
<a class="mb-2"
class="nav-link active" >
href="#settings" {{ $gettext('Title') }}
data-toggle="tab" </label>
> <div class="controls">
{{ $gettext('Settings') }} <input
</a> id="metadata_title"
</li> v-model="shownMetadata.title"
<li class="nav-item"> class="form-control"
<a type="text"
class="nav-link" >
href="#metadata" </div>
data-toggle="tab" </div>
> <div class="form-group">
{{ $gettext('Metadata') }} <label
</a> for="metadata_artist"
</li> class="mb-2"
</ul> >
{{ $gettext('Artist') }}
</label>
<div class="controls">
<input
id="metadata_artist"
v-model="shownMetadata.artist"
class="form-control"
type="text"
>
</div>
</div>
<div class="form-group">
<button
class="btn btn-primary"
@click="updateMetadata"
>
{{ $gettext('Update Metadata') }}
</button>
</div> </div>
</div> </div>
<div class="form-row"> </template>
<div class="col-sm-12"> <template v-else>
<div class="tab-content mt-1"> <div class="card-body alert-info">
<div <p class="card-text">
id="settings" {{ $gettext('The WebDJ lets you broadcast live to your station using just your web browser.') }}
class="tab-pane active" </p>
> <p class="card-text">
<div class="form-group"> {{
<label class="mb-2"> $gettext('To use this feature, a secure (HTTPS) connection is required. Firefox is recommended to avoid static when broadcasting.')
{{ $gettext('Encoder') }} }}
</label> </p>
<div class="controls"> </div>
<div class="custom-control custom-radio custom-control-inline">
<input
id="encoder_mp3"
v-model="encoder"
type="radio"
value="mp3"
class="custom-control-input"
>
<label
for="encoder_mp3"
class="custom-control-label"
>
{{ $gettext('MP3') }}
</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input
id="encoder_raw"
v-model="encoder"
type="radio"
value="raw"
class="custom-control-input"
>
<label
for="encoder_raw"
class="custom-control-label"
>
{{ $gettext('Raw') }}
</label>
</div>
</div>
</div>
<div class="form-group">
<label
for="select_samplerate"
class="mb-2"
>
{{ $gettext('Sample Rate') }}
</label>
<div class="controls">
<select
id="select_samplerate"
v-model.number="samplerate"
class="form-control"
>
<option value="8000">
8 kHz
</option>
<option value="11025">
11.025 kHz
</option>
<option value="12000">
12 kHz
</option>
<option value="16000">
16 kHz
</option>
<option value="22050">
22.05 kHz
</option>
<option value="24000">
24 kHz
</option>
<option value="32000">
32 kHz
</option>
<option value="44100">
44.1 kHz
</option>
<option value="48000">
48 kHz
</option>
</select>
</div>
</div>
<div class="form-group">
<label
for="select_bitrate"
class="mb-2"
>
{{ $gettext('Bit Rate') }}
</label>
<div class="controls">
<select
id="select_bitrate"
v-model.number="bitrate"
class="form-control"
>
<option value="8">
8 kbps
</option>
<option value="16">
16 kbps
</option>
<option value="24">
24 kbps
</option>
<option value="32">
32 kbps
</option>
<option value="40">
40 kbps
</option>
<option value="48">
48 kbps
</option>
<option value="56">
56 kbps
</option>
<option value="64">
64 kbps
</option>
<option value="80">
80 kbps
</option>
<option value="96">
96 kbps
</option>
<option value="112">
112 kbps
</option>
<option value="128">
128 kbps
</option>
<option value="144">
144 kbps
</option>
<option value="160">
160 kbps
</option>
<option value="192">
192 kbps
</option>
<option value="224">
224 kbps
</option>
<option value="256">
256 kbps
</option>
<option value="320">
320 kbps
</option>
</select>
</div>
</div>
<div class="form-group">
<label class="mb-2">
{{ $gettext('DJ Credentials') }}
</label>
<div class="form-row"> <div class="card-body">
<div class="col-6"> <div class="form-group">
<input <label
v-model="djUsername" for="dj_username"
type="text" class="mb-2"
class="form-control" >
:placeholder="$gettext('Username')" {{ $gettext('Streamer/DJ Username') }}
> </label>
</div> <div class="controls">
<div class="col-6"> <input
<input id="dj_username"
v-model="djPassword" v-model="djUsername"
type="password" type="text"
class="form-control" class="form-control"
:placeholder="$gettext('Password')" >
> </div>
</div> </div>
</div> <div class="form-group">
</div> <label
<div class="form-group mb-0"> for="dj_password"
<div class="custom-control custom-checkbox"> class="mb-2"
<input >
id="use_async_worker" {{ $gettext('Streamer/DJ Password') }}
v-model="asynchronous" </label>
type="checkbox" <div class="controls">
class="custom-control-input" <input
> id="dj_password"
<label v-model="djPassword"
for="use_async_worker" type="password"
class="custom-control-label" class="form-control"
>
{{ $gettext('Use Asynchronous Worker') }}
</label>
</div>
</div>
</div>
<div
id="metadata"
class="tab-pane"
> >
<div class="form-group">
<label
for="metadata_title"
class="mb-2"
>
{{ $gettext('Title') }}
</label>
<div class="controls">
<input
id="metadata_title"
v-model="metadata.title"
class="form-control"
type="text"
:disabled="!isStreaming"
>
</div>
</div>
<div class="form-group">
<label
for="metadata_artist"
class="mb-2"
>
{{ $gettext('Artist') }}
</label>
<div class="controls">
<input
id="metadata_artist"
v-model="metadata.artist"
class="form-control"
type="text"
:disabled="!isStreaming"
>
</div>
</div>
<div class="form-group">
<button
class="btn btn-primary"
:disabled="!isStreaming"
@click="updateMetadata"
>
{{ $gettext('Update Metadata') }}
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </template>
<div class="card-actions"> <div class="card-actions">
<button <button
v-if="!isStreaming" v-if="!isConnected"
class="btn btn-success" class="btn btn-success"
@click="startStreaming" @click="startStream(djUsername, djPassword)"
> >
{{ langStreamButton }} {{ langStreamButton }}
</button> </button>
<button <button
v-if="isStreaming" v-if="isConnected"
class="btn btn-danger" class="btn btn-danger"
@click="stopStreaming" @click="stopStream"
> >
{{ langStreamButton }} {{ langStreamButton }}
</button> </button>
<button <button
class="btn" class="btn"
:class="{ 'btn-primary': passThrough }" :class="{ 'btn-primary': doPassThrough }"
@click="cue" @click="doPassThrough = !doPassThrough"
> >
{{ $gettext('Cue') }} {{ $gettext('Cue') }}
</button> </button>
@ -309,129 +125,54 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { import {computed, ref, watch} from "vue";
inject: ['getStream', 'resumeStream'], import {useTranslate} from "~/vendor/gettext";
props: { import {useInjectWebDjNode} from "~/components/Public/WebDJ/useWebDjNode";
stationName: { import {usePassthroughSync} from "~/components/Public/WebDJ/usePassthroughSync";
type: String,
required: true
},
libUrls: {
type: Array,
default: () => {
return [];
}
},
baseUri: {
type: String,
required: true
}
},
data () {
return {
'isStreaming': false,
'djUsername': '',
'djPassword': '',
'bitrate': 256,
'samplerate': 44100,
'encoder': 'mp3',
'asynchronous': true,
'passThrough': false,
'metadata': {
'title': '',
'artist': ''
}
};
},
computed: {
langStreamButton () {
return (this.isStreaming)
? this.$gettext('Stop Streaming')
: this.$gettext('Start Streaming');
},
uri () {
return 'wss://' + this.djUsername + ':' + this.djPassword + '@' + this.baseUri;
}
},
mounted () {
this.$root.$on('new-cue', this.onNewCue);
this.$root.$on('metadata-update', this.onMetadataUpdate);
},
methods: {
cue () {
this.resumeStream();
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : 'master'); const props = defineProps({
}, stationName: {
onNewCue (new_cue) { type: String,
this.passThrough = (new_cue === 'master'); required: true
this.getStream().webcast.setPassThrough(this.passThrough);
},
startStreaming () {
this.resumeStream();
let encoderClass;
switch (this.encoder) {
case 'mp3':
encoderClass = Webcast.Encoder.Mp3;
break;
case 'raw':
encoderClass = Webcast.Encoder.Raw;
}
let encoder = new encoderClass({
channels: 2,
samplerate: this.samplerate,
bitrate: this.bitrate
});
if (this.samplerate !== this.getStream().context.sampleRate) {
encoder = new Webcast.Encoder.Resample({
encoder: encoder,
type: Samplerate.LINEAR,
samplerate: this.getStream().context.sampleRate
});
}
if (this.asynchronous) {
encoder = new Webcast.Encoder.Asynchronous({
encoder: encoder,
scripts: this.libUrls
});
}
let socket = this.getStream().webcast.connectSocket(encoder, this.uri);
socket.addEventListener("open", () => {
this.$notifySuccess(this.$gettext('Live stream connected.'));
this.isStreaming = true;
this.updateMetadata(false);
});
socket.addEventListener("close", () => {
this.$notifyError(this.$gettext('Live stream disconnected.'));
this.isStreaming = false;
});
},
stopStreaming () {
this.getStream().webcast.close();
this.isStreaming = false;
},
updateMetadata(alert = true) {
this.$root.$emit('metadata-update', {
title: this.metadata.title,
artist: this.metadata.artist
});
if (alert) {
this.$notifySuccess(this.$gettext('Metadata updated!'));
}
},
onMetadataUpdate (new_metadata) {
this.metadata.title = new_metadata.title;
this.metadata.artist = new_metadata.artist;
return this.getStream().webcast.sendMetadata(new_metadata);
}
} }
});
const djUsername = ref(null);
const djPassword = ref(null);
const {
doPassThrough,
isConnected,
startStream,
stopStream,
metadata,
sendMetadata
} = useInjectWebDjNode();
usePassthroughSync(doPassThrough, 'global');
const {$gettext} = useTranslate();
const langStreamButton = computed(() => {
return (isConnected.value)
? $gettext('Stop Streaming')
: $gettext('Start Streaming');
});
const shownMetadata = ref({});
watch(metadata, (newMeta) => {
if (newMeta === null) {
newMeta = {
artist: '',
title: ''
};
}
shownMetadata.value = newMeta;
});
const updateMetadata = () => {
sendMetadata(shownMetadata.value);
}; };
</script> </script>

View File

@ -1,106 +0,0 @@
// noinspection all
// @eslint-
var stream = {};
var defaultChannels = 2;
// Function to be called upon the first user interaction.
stream.init = function () {
// Define the streaming radio context.
if (!this.context) {
if (typeof webkitAudioContext !== 'undefined') {
this.context = new webkitAudioContext;
} else {
this.context = new AudioContext;
}
this.webcast = this.context.createWebcastSource(4096, defaultChannels);
this.webcast.connect(this.context.destination);
}
};
stream.resumeContext = function () {
if (this.context.state !== 'running') {
this.context.resume();
}
};
stream.createAudioSource = function ({
file,
audio
}, model, cb) {
var el,
source;
el = new Audio(URL.createObjectURL(file));
el.controls = false;
el.autoplay = false;
el.loop = false;
el.addEventListener('ended', function () {
return model.onEnd();
});
source = null;
return el.addEventListener('canplay', function () {
if (source != null) {
return;
}
source = stream.context.createMediaElementSource(el);
source.play = function () {
return el.play();
};
source.position = function () {
return el.currentTime;
};
source.duration = function () {
return el.duration;
};
source.paused = function () {
return el.paused;
};
source.stop = function () {
el.pause();
return el.remove();
};
source.pause = function () {
return el.pause();
};
source.seek = function (percent) {
var time;
time = percent * parseFloat(audio.length);
el.currentTime = time;
return time;
};
return cb(source);
});
};
stream.createFileSource = function (file, model, cb) {
var ref;
if ((ref = this.source) != null) {
ref.disconnect();
}
return this.createAudioSource(file, model, cb);
};
stream.createMicrophoneSource = function (constraints, cb) {
return navigator.mediaDevices.getUserMedia(constraints).then(function (bit_stream) {
var source;
source = stream.context.createMediaStreamSource(bit_stream);
source.stop = function () {
var ref;
return (ref = bit_stream.getAudioTracks()) != null ? ref[0].stop() : void 0;
};
return cb(source);
});
};
stream.close = function (cb) {
return this.webcast.close(cb);
};
export default stream;

View File

@ -1,222 +0,0 @@
// noinspection all
export default {
inject: ['getStream', 'resumeStream'],
data: function () {
return {
'controlsNode': null,
'trackGain': 0,
'trackGainObj': null,
'destination': null,
'sink': null,
'passThrough': false,
'passThroughObj': null,
'source': null,
'playing': false,
'paused': false,
'position': 0.0,
'volume': 100,
'volumeLeft': 0,
'volumeRight': 0
};
},
mounted: function () {
this.sink = this.getStream().webcast;
},
watch: {
volume: function (val, oldVal) {
this.setTrackGain(val);
}
},
methods: {
createControlsNode: function () {
var bufferLength,
bufferLog,
bufferSize,
log10,
source;
bufferSize = 4096;
bufferLength = parseFloat(bufferSize) / parseFloat(this.getStream().context.sampleRate);
bufferLog = Math.log(parseFloat(bufferSize));
log10 = 2.0 * Math.log(10);
source = this.getStream().context.createScriptProcessor(bufferSize, 2, 2);
source.onaudioprocess = (buf) => {
var channel,
channelData,
i,
j,
k,
ref1,
ref2,
ref3,
results,
ret,
rms,
volume;
ret = {};
if (((ref1 = this.source) != null ? ref1.position : void 0) != null) {
this.position = this.source.position();
} else {
if (this.source != null) {
this.position = parseFloat(this.position) + bufferLength;
}
}
results = [];
for (channel = j = 0, ref2 = buf.inputBuffer.numberOfChannels - 1; (0 <= ref2 ? j <= ref2 : j >= ref2); channel = 0 <= ref2 ? ++j : --j) {
channelData = buf.inputBuffer.getChannelData(channel);
rms = 0.0;
for (i = k = 0, ref3 = channelData.length - 1; (0 <= ref3 ? k <= ref3 : k >= ref3); i = 0 <= ref3 ? ++k : --k) {
rms += Math.pow(channelData[i], 2);
}
volume = 100 * Math.exp((Math.log(rms) - bufferLog) / log10);
if (channel === 0) {
this.volumeLeft = volume;
} else {
this.volumeRight = volume;
}
results.push(buf.outputBuffer.getChannelData(channel).set(channelData));
}
return results;
};
return source;
},
createPassThrough: function () {
var source;
source = this.getStream().context.createScriptProcessor(256, 2, 2);
source.onaudioprocess = (buf) => {
var channel,
channelData,
j,
ref1,
results;
channelData = buf.inputBuffer.getChannelData(channel);
results = [];
for (channel = j = 0, ref1 = buf.inputBuffer.numberOfChannels - 1; (0 <= ref1 ? j <= ref1 : j >= ref1); channel = 0 <= ref1 ? ++j : --j) {
if (this.passThrough) {
results.push(buf.outputBuffer.getChannelData(channel).set(channelData));
} else {
results.push(buf.outputBuffer.getChannelData(channel).set(new Float32Array(channelData.length)));
}
}
return results;
};
return source;
},
setTrackGain: function (new_gain) {
return (this.trackGainObj) && (this.trackGainObj.gain.value = parseFloat(new_gain) / 100.0);
},
togglePause: function () {
var ref1,
ref2;
if (((ref1 = this.source) != null ? ref1.pause : void 0) == null) {
return;
}
if ((ref2 = this.source) != null ? typeof ref2.paused === 'function' ? ref2.paused() : void 0 : void 0) {
this.source.play();
this.playing = true;
this.paused = false;
} else {
this.source.pause();
this.playing = false;
this.paused = true;
}
},
prepare: function () {
this.controlsNode = this.createControlsNode();
this.controlsNode.connect(this.sink);
this.trackGainObj = this.getStream().context.createGain();
this.trackGainObj.connect(this.controlsNode);
this.trackGainObj.gain.value = 1.0;
this.destination = this.trackGainObj;
this.passThroughObj = this.createPassThrough();
this.passThroughObj.connect(this.getStream().context.destination);
return this.trackGainObj.connect(this.passThroughObj);
},
stop: function () {
var ref1,
ref2,
ref3,
ref4,
ref5;
if ((ref1 = this.source) != null) {
if (typeof ref1.stop === 'function') {
ref1.stop();
}
}
if ((ref2 = this.source) != null) {
ref2.disconnect();
}
if ((ref3 = this.trackGainObj) != null) {
ref3.disconnect();
}
if ((ref4 = this.controlsNode) != null) {
ref4.disconnect();
}
if ((ref5 = this.passThroughObj) != null) {
ref5.disconnect();
}
this.source = this.trackGainObj = this.controlsNode = this.passThroughObj = null;
this.position = 0.0;
this.volumeLeft = 0;
this.volumeRight = 0;
this.playing = false;
this.paused = false;
},
seek: function (percent) {
var position,
ref1;
if (!(position = (ref1 = this.source) != null ? typeof ref1.seek === 'function' ? ref1.seek(percent) : void 0 : void 0)) {
return;
}
this.position = position;
},
prettifyTime: function (time) {
var hours,
minutes,
result,
seconds;
hours = parseInt(time / 3600);
time %= 3600;
minutes = parseInt(time / 60);
seconds = parseInt(time % 60);
if (minutes < 10) {
minutes = `0${minutes}`;
}
if (seconds < 10) {
seconds = `0${seconds}`;
}
result = `${minutes}:${seconds}`;
if (hours > 0) {
result = `${hours}:${result}`;
}
return result;
},
sendMetadata: function (file) {
this.getStream().webcast.sendMetadata(file.metadata);
}
}
};

View File

@ -27,7 +27,7 @@ import {useVModel} from "@vueuse/core";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: String, type: Number,
required: true required: true
} }
}); });

View File

@ -0,0 +1,11 @@
import {inject, provide} from "vue";
const injectKey = "webDjMixer";
export function useProvideMixer(mixer) {
provide(injectKey, mixer);
}
export function useInjectMixer() {
return inject(injectKey);
}

View File

@ -0,0 +1,27 @@
import {inject, provide, watch} from "vue";
const injectKey = 'webDjPassthroughSync';
export function useProvidePassthroughSync (passthroughSync) {
provide(injectKey, passthroughSync);
}
export function useInjectPassthroughSync() {
return inject(injectKey);
}
export function usePassthroughSync(thisPassthrough, stringVal) {
const passthroughSync = useInjectPassthroughSync();
watch(passthroughSync, (newVal) => {
if (newVal !== stringVal) {
thisPassthrough.value = false;
}
});
watch(thisPassthrough, (newVal) => {
if (newVal) {
passthroughSync.value = stringVal;
}
});
}

View File

@ -0,0 +1,148 @@
import {inject, provide, ref} from "vue";
const injectKey = "webDjNode";
export function useInjectWebDjNode() {
return inject(injectKey);
}
export function useProvideWebDjNode(node) {
provide(injectKey, node);
}
export function useWebDjNode(webcaster) {
const {isConnected, connect: connectSocket, metadata, sendMetadata} = webcaster;
const doPassThrough = ref(false);
const context = new AudioContext({
sampleRate: 44100
});
const sink = context.createScriptProcessor(256, 2, 2);
sink.onaudioprocess = (buf) => {
for (let channel = 0; channel < buf.inputBuffer.numberOfChannels; channel++) {
let channelData = buf.inputBuffer.getChannelData(channel);
buf.outputBuffer.getChannelData(channel).set(channelData);
}
};
const passThrough = context.createScriptProcessor(256, 2, 2);
passThrough.onaudioprocess = (buf) => {
for (let channel = 0; channel < buf.inputBuffer.numberOfChannels; channel++) {
let channelData = buf.inputBuffer.getChannelData(channel);
if (doPassThrough.value) {
buf.outputBuffer.getChannelData(channel).set(channelData);
} else {
buf.outputBuffer.getChannelData(channel).set(new Float32Array(channelData.length));
}
}
};
sink.connect(passThrough);
passThrough.connect(context.destination);
const streamNode = context.createMediaStreamDestination();
streamNode.channelCount = 2;
sink.connect(streamNode);
let mediaRecorder;
const startStream = (username = null, password = null) => {
context.resume();
mediaRecorder = new MediaRecorder(
streamNode.stream,
{
mimeType: "audio/webm;codecs=opus",
audioBitsPerSecond: 128 * 1000
}
);
connectSocket(mediaRecorder, username, password);
mediaRecorder.start(1000);
}
const stopStream = () => {
mediaRecorder?.stop();
};
const createAudioSource = ({file, audio}, cb, onEnd) => {
const el = new Audio(URL.createObjectURL(file));
el.controls = false;
el.autoplay = false;
el.loop = false;
let source = null;
el.addEventListener("ended", () => {
if (typeof onEnd === "function") {
onEnd();
}
});
el.addEventListener("canplay", () => {
if (source) {
return;
}
source = context.createMediaElementSource(el);
source.play = () => el.play()
source.position = () => el.currentTime;
source.duration = () => el.duration;
source.paused = () => el.paused;
source.stop = () => {
el.pause();
return el.remove();
};
source.pause = () => el.pause;
source.seek = (percent) => {
let time = percent * parseFloat(audio.length);
el.currentTime = time;
return time;
};
return cb(source);
});
};
const createMicrophoneSource = (audioDeviceId, cb) => {
navigator.mediaDevices.getUserMedia({
video: false,
audio: {
deviceId: audioDeviceId
}
}).then((stream) => {
let source = context.createMediaStreamSource(stream);
source.stop = () => {
let ref = stream.getAudioTracks();
return (ref !== null)
? ref[0].stop()
: 0;
}
return cb(source);
});
};
return {
doPassThrough,
isConnected,
context,
sink,
passThrough,
streamNode,
startStream,
stopStream,
createAudioSource,
createMicrophoneSource,
metadata,
sendMetadata
};
}

View File

@ -0,0 +1,147 @@
import {computed, ref, watch} from "vue";
import {useInjectWebDjNode} from "~/components/Public/WebDJ/useWebDjNode";
export function useWebDjTrack() {
const {
context,
sink,
createMicrophoneSource,
createAudioSource,
sendMetadata
} = useInjectWebDjNode();
const trackGain = ref(55);
const trackPassThrough = ref(false);
const position = ref(null);
const volume = ref(0);
let source = ref(null);
const createControlsNode = () => {
const bufferSize = 4096;
const bufferLog = Math.log(parseFloat(bufferSize));
const log10 = 2.0 * Math.log(10);
let newSource = context.createScriptProcessor(bufferSize, 2, 2);
newSource.onaudioprocess = (buf) => {
if (typeof (source.value?.position) === "function") {
position.value = source.value.position();
}
for (let channel = 0; channel < buf.inputBuffer.numberOfChannels; channel++) {
let channelData = buf.inputBuffer.getChannelData(channel);
let rms = 0.0;
for (let i = 0; i < channelData.length; i++) {
rms += Math.pow(channelData[i], 2);
}
volume.value = 100 * Math.exp((Math.log(rms) - bufferLog) / log10);
buf.outputBuffer.getChannelData(channel).set(channelData);
}
};
return newSource;
};
const createPassThrough = () => {
let newSource = context.createScriptProcessor(256, 2, 2);
newSource.onaudioprocess = (buf) => {
for (let channel = 0; channel < buf.inputBuffer.numberOfChannels; channel++) {
let channelData = buf.inputBuffer.getChannelData(channel);
if (trackPassThrough.value) {
buf.outputBuffer.getChannelData(channel).set(channelData);
} else {
buf.outputBuffer.getChannelData(channel).set(new Float32Array(channelData.length));
}
}
};
return newSource;
};
let controlsNode = null;
let trackGainNode = null;
let passThroughNode = null;
const setTrackGain = (newGain) => {
if (null === trackGainNode) {
return;
}
trackGainNode.gain.value = parseFloat(newGain) / 100.0;
};
watch(trackGain, setTrackGain);
const prepare = () => {
controlsNode = createControlsNode();
controlsNode.connect(sink);
trackGainNode = context.createGain();
trackGainNode.gain.value = parseFloat(trackGain.value) / 100.0;
trackGainNode.connect(controlsNode);
passThroughNode = createPassThrough();
passThroughNode.connect(context.destination);
trackGainNode.connect(passThroughNode);
context.resume();
return trackGainNode;
}
const isPlaying = computed(() => {
return source.value !== null;
});
const isPaused = computed(() => {
return (source.value !== null)
? source.value.paused()
: false;
});
const togglePause = () => {
if (source.value === null) {
return;
}
if (source.value.paused()) {
source.value.play();
} else {
source.value.pause();
}
};
const stop = () => {
source.value?.stop();
source.value?.disconnect();
trackGainNode?.disconnect();
controlsNode?.disconnect();
passThroughNode?.disconnect();
source.value = trackGainNode = controlsNode = passThroughNode = null;
position.value = 0.0;
};
return {
createMicrophoneSource,
createAudioSource,
sendMetadata,
source,
trackGain,
trackPassThrough,
position,
volume,
isPlaying,
isPaused,
prepare,
togglePause,
stop,
};
}

View File

@ -0,0 +1,100 @@
import {ref, shallowRef} from "vue";
import {useNotify} from "~/vendor/bootstrapVue";
import {useTranslate} from "~/vendor/gettext";
export const webcasterProps = {
baseUri: {
type: String,
required: true
}
};
export function useWebcaster(props) {
const {baseUri} = props;
const {notifySuccess, notifyError} = useNotify();
const {$gettext} = useTranslate();
const metadata = shallowRef(null);
const isConnected = ref(false);
let socket = null;
const sendMetadata = (data) => {
metadata.value = data;
if (isConnected.value) {
socket.send(JSON.stringify({
type: "metadata",
data,
}));
}
}
const connect = (mediaRecorder, username = null, password = null) => {
socket = new WebSocket(baseUri, "webcast");
let hello = {
mime: mediaRecorder.mimeType,
};
if (null !== username) {
hello.user = username;
}
if (null !== password) {
hello.password = password;
}
socket.onopen = () => {
socket.send(JSON.stringify({
type: "hello",
data: hello
}));
isConnected.value = true;
// Timeout as Liquidsoap won't return any success/failure message, so the only
// way we know if we're still connected is to set a timer.
setTimeout(() => {
if (isConnected.value) {
notifySuccess($gettext('WebDJ connected!'));
if (metadata.value !== null) {
socket.send(JSON.stringify({
type: "metadata",
data: metadata.value
}));
}
}
}, 1000);
};
socket.onerror = () => {
notifyError($gettext('An error occurred with the WebDJ socket.'));
}
socket.onclose = () => {
isConnected.value = false;
};
mediaRecorder.ondataavailable = async (e) => {
const data = await e.data.arrayBuffer();
if (isConnected.value) {
socket.send(data);
}
};
mediaRecorder.onstop = () => {
if (isConnected.value) {
socket.close();
}
};
};
return {
isConnected,
connect,
metadata,
sendMetadata
}
}

View File

@ -39,7 +39,6 @@ final class WebDjAction
} }
$wss_url = (string)$backend->getWebStreamingUrl($station, $request->getRouter()->getBaseUrl()); $wss_url = (string)$backend->getWebStreamingUrl($station, $request->getRouter()->getBaseUrl());
$wss_url = str_replace('wss://', '', $wss_url);
return $request->getView()->renderToResponse( return $request->getView()->renderToResponse(
response: $response->withHeader('X-Frame-Options', '*'), response: $response->withHeader('X-Frame-Options', '*'),

View File

@ -1,6 +1,8 @@
<?php <?php
/** /**
* @var App\View\GlobalSections $sections * @var App\View\GlobalSections $sections
* @var App\Entity\Station $station
* @var string $wss_url
*/ */
$this->layout( $this->layout(
@ -13,26 +15,11 @@ $this->layout(
] ]
); );
$jsLibs = [ $sections->appendStart('bodyjs');
$this->assetUrl('dist/lib/webcaster/libshine.js'), ?>
$this->assetUrl('dist/lib/webcaster/libsamplerate.js'), <script src="<?= $this->assetUrl('dist/lib/webcaster/taglib.js') ?>"></script>
$this->assetUrl('dist/lib/webcaster/taglib.js'), <?php
$this->assetUrl('dist/lib/webcaster/webcast.js'), $sections->end();
];
$libUrls = [];
foreach ($jsLibs as $script) {
$libUrls[] = (string)($router->getBaseUrl()->withPath($script));
}
$scriptLines = [];
foreach ($jsLibs as $jsLib) {
$scriptLines[] = <<<HTML
<script src="{$jsLib}"></script>
HTML;
}
$sections->append('bodyjs', implode("\n", $scriptLines));
echo $this->fetch( echo $this->fetch(
'partials/vue_body', 'partials/vue_body',
@ -41,7 +28,6 @@ echo $this->fetch(
'id' => 'web_dj', 'id' => 'web_dj',
'props' => [ 'props' => [
'stationName' => $station->getName(), 'stationName' => $station->getName(),
'libUrls' => $libUrls,
'baseUri' => $wss_url, 'baseUri' => $wss_url,
], ],
] ]