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/*

View File

@ -2,7 +2,7 @@
import gulp from 'gulp';
import babel from 'gulp-babel';
import { deleteAsync as del } from 'del';
import {deleteAsync as del} from 'del';
import rev from 'gulp-rev';
import concat from 'gulp-concat';
import uglify from 'gulp-uglify';
@ -66,7 +66,7 @@ const jsFiles = {
files: [
'js/webcaster/*.js'
]
},
}
};
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="form-row">
<div class="col-md-4 mb-sm-4">
<settings-panel v-bind="{ stationName, baseUri, libUrls }" />
<settings-panel :station-name="stationName" />
</div>
<div class="col-md-8">
@ -36,57 +36,33 @@
</section>
</template>
<script>
<script setup>
import MixerPanel from './WebDJ/MixerPanel.vue';
import MicrophonePanel from './WebDJ/MicrophonePanel.vue';
import PlaylistPanel from './WebDJ/PlaylistPanel.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 {
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();
const webcaster = useWebcaster(props);
return this.stream;
},
resumeStream: function () {
this.stream.resumeContext();
}
}
};
const node = useWebDjNode(webcaster);
useProvideWebDjNode(node);
const mixer = ref(1.0);
useProvideMixer(mixer);
const passthroughSync = ref('');
useProvidePassthroughSync(passthroughSync);
</script>

View File

@ -8,7 +8,7 @@
</h5>
</div>
<div class="flex-shrink-0 pl-3">
<volume-slider v-model.number="volume" />
<volume-slider v-model.number="trackGain" />
</div>
</div>
</div>
@ -20,15 +20,15 @@
<div class="btn-group btn-group-sm">
<button
class="btn btn-danger"
:class="{ active: playing }"
@click="toggleRecording"
:class="{ active: isPlaying }"
@click="togglePlaying"
>
<icon icon="mic" />
</button>
<button
class="btn"
:class="{ 'btn-primary': passThrough }"
@click="cue"
:class="{ 'btn-primary': trackPassThrough }"
@click="trackPassThrough = !trackPassThrough"
>
{{ $gettext('Cue') }}
</button>
@ -50,7 +50,8 @@
class="form-control"
>
<option
v-for="device_row in devices"
v-for="device_row in audioInputs"
:key="device_row.deviceId"
:value="device_row.deviceId"
>
{{ device_row.label }}
@ -62,138 +63,83 @@
</div>
<div
v-if="playing"
v-if="isPlaying"
class="mt-3"
>
<div class="progress mb-1">
<div
class="progress-bar"
:style="{ width: volumeLeft+'%' }"
/>
</div>
<div class="progress mb-2">
<div
class="progress-bar"
:style="{ width: volumeRight+'%' }"
:style="{ width: volume+'%' }"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import track from './Track.js';
import {first, filter, isEmpty} from 'lodash';
<script setup>
import Icon from '~/components/Common/Icon';
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 {
components: {VolumeSlider, Icon},
extends: track,
const {
createMicrophoneSource,
source,
isPlaying,
trackGain,
trackPassThrough,
volume,
prepare,
stop
} = useWebDjTrack();
data: function () {
return {
'device': null,
'devices': [],
'isRecording': false
};
},
watch: {
device: function () {
if (this.source == null) {
return;
}
return this.createSource();
}
},
mounted: function () {
let base, base1;
usePassthroughSync(trackPassThrough, 'microphone');
// Get multimedia devices by requesting them from the browser.
navigator.mediaDevices || (navigator.mediaDevices = {});
const {audioInputs} = useDevicesList({
requestPermissions: true,
constraints: {audio: true, video: false}
});
(base = navigator.mediaDevices).getUserMedia || (base.getUserMedia = function (constraints) {
let fn;
fn = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
if (fn == null) {
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;
}
const device = ref(null);
watch(audioInputs, (inputs) => {
if (device.value === null) {
device.value = inputs[0]?.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>

View File

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

View File

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

View File

@ -7,301 +7,117 @@
<small>{{ stationName }}</small>
</h5>
</div>
<div class="card-body pt-0">
<div class="form-row pb-4">
<div class="col-sm-12">
<ul class="nav nav-tabs card-header-tabs mt-0">
<li class="nav-item">
<a
class="nav-link active"
href="#settings"
data-toggle="tab"
>
{{ $gettext('Settings') }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="#metadata"
data-toggle="tab"
>
{{ $gettext('Metadata') }}
</a>
</li>
</ul>
<template v-if="isConnected">
<div class="card-body">
<div class="form-group">
<label
for="metadata_title"
class="mb-2"
>
{{ $gettext('Title') }}
</label>
<div class="controls">
<input
id="metadata_title"
v-model="shownMetadata.title"
class="form-control"
type="text"
>
</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="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 class="form-row">
<div class="col-sm-12">
<div class="tab-content mt-1">
<div
id="settings"
class="tab-pane active"
>
<div class="form-group">
<label class="mb-2">
{{ $gettext('Encoder') }}
</label>
<div class="controls">
<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>
</template>
<template v-else>
<div class="card-body alert-info">
<p class="card-text">
{{ $gettext('The WebDJ lets you broadcast live to your station using just your web browser.') }}
</p>
<p class="card-text">
{{
$gettext('To use this feature, a secure (HTTPS) connection is required. Firefox is recommended to avoid static when broadcasting.')
}}
</p>
</div>
<div class="form-row">
<div class="col-6">
<input
v-model="djUsername"
type="text"
class="form-control"
:placeholder="$gettext('Username')"
>
</div>
<div class="col-6">
<input
v-model="djPassword"
type="password"
class="form-control"
:placeholder="$gettext('Password')"
>
</div>
</div>
</div>
<div class="form-group mb-0">
<div class="custom-control custom-checkbox">
<input
id="use_async_worker"
v-model="asynchronous"
type="checkbox"
class="custom-control-input"
>
<label
for="use_async_worker"
class="custom-control-label"
>
{{ $gettext('Use Asynchronous Worker') }}
</label>
</div>
</div>
</div>
<div
id="metadata"
class="tab-pane"
<div class="card-body">
<div class="form-group">
<label
for="dj_username"
class="mb-2"
>
{{ $gettext('Streamer/DJ Username') }}
</label>
<div class="controls">
<input
id="dj_username"
v-model="djUsername"
type="text"
class="form-control"
>
</div>
</div>
<div class="form-group">
<label
for="dj_password"
class="mb-2"
>
{{ $gettext('Streamer/DJ Password') }}
</label>
<div class="controls">
<input
id="dj_password"
v-model="djPassword"
type="password"
class="form-control"
>
<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>
</template>
<div class="card-actions">
<button
v-if="!isStreaming"
v-if="!isConnected"
class="btn btn-success"
@click="startStreaming"
@click="startStream(djUsername, djPassword)"
>
{{ langStreamButton }}
</button>
<button
v-if="isStreaming"
v-if="isConnected"
class="btn btn-danger"
@click="stopStreaming"
@click="stopStream"
>
{{ langStreamButton }}
</button>
<button
class="btn"
:class="{ 'btn-primary': passThrough }"
@click="cue"
:class="{ 'btn-primary': doPassThrough }"
@click="doPassThrough = !doPassThrough"
>
{{ $gettext('Cue') }}
</button>
@ -309,129 +125,54 @@
</div>
</template>
<script>
export default {
inject: ['getStream', 'resumeStream'],
props: {
stationName: {
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();
<script setup>
import {computed, ref, watch} from "vue";
import {useTranslate} from "~/vendor/gettext";
import {useInjectWebDjNode} from "~/components/Public/WebDJ/useWebDjNode";
import {usePassthroughSync} from "~/components/Public/WebDJ/usePassthroughSync";
this.$root.$emit('new-cue', (this.passThrough) ? 'off' : 'master');
},
onNewCue (new_cue) {
this.passThrough = (new_cue === 'master');
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 props = defineProps({
stationName: {
type: String,
required: true
}
});
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>

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({
modelValue: {
type: String,
type: Number,
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 = str_replace('wss://', '', $wss_url);
return $request->getView()->renderToResponse(
response: $response->withHeader('X-Frame-Options', '*'),

View File

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