jcheng5's picture
Initial checkin
21e6506
"use strict";
// srcts/videoClipper.ts
var VideoClipperElement = class extends HTMLElement {
constructor() {
super();
this.chunks = [];
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
:host {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
width: 100%;
height: min-content;
}
video {
grid-column: 1 / 2;
grid-row: 1 / 2;
width: 100%;
object-fit: cover;
background-color: var(--video-clip-bg, black);
aspect-ratio: 16 / 9;
border-radius: var(--video-clip-border-radius, var(--bs-border-radius-lg));
}
video.mirrored {
transform: scaleX(-1);
}
.panel-settings {
grid-column: 1 / 2;
grid-row: 1 / 2;
justify-self: end;
margin: 0.5em;
}
.panel-buttons {
grid-column: 1 / 2;
grid-row: 1 / 2;
justify-self: end;
align-self: end;
margin: 0.5em;
}
</style>
<video part="video" muted></video>
<div class="panel-settings">
<slot name="settings"></slot>
</div>
<div class="panel-buttons">
<slot name="recording-controls"></slot>
</div>
`;
this.video = this.shadowRoot.querySelector("video");
}
connectedCallback() {
(async () => {
const slotSettings = this.shadowRoot.querySelector(
"slot[name=settings]"
);
slotSettings.addEventListener("slotchange", async () => {
this.avSettingsMenu = slotSettings.assignedElements()[0];
await this.#initializeMediaInput();
if (this.buttonRecord) {
this.#setEnabledButton(this.buttonRecord);
}
});
const slotControls = this.shadowRoot.querySelector(
"slot[name=recording-controls]"
);
slotControls.addEventListener("slotchange", () => {
const findButton = (selector) => {
for (const el of slotControls.assignedElements()) {
if (el.matches(selector)) {
return el;
}
const sub = el.querySelector(selector);
if (sub) {
return sub;
}
}
return null;
};
this.buttonRecord = findButton(".record-button");
this.buttonStop = findButton(".stop-button");
this.#setEnabledButton();
this.buttonRecord.addEventListener("click", () => {
this.#setEnabledButton(this.buttonStop);
this._beginRecord();
});
this.buttonStop.addEventListener("click", () => {
this._endRecord();
this.#setEnabledButton(this.buttonRecord);
});
});
})().catch((err) => {
console.error(err);
});
}
disconnectedCallback() {
}
#setEnabledButton(btn) {
this.buttonRecord.style.display = btn === this.buttonRecord ? "inline-block" : "none";
this.buttonStop.style.display = btn === this.buttonStop ? "inline-block" : "none";
}
async setMediaDevices(cameraId, micId) {
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
}
this.cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: cameraId || void 0,
facingMode: "user",
aspectRatio: 16 / 9
},
audio: {
deviceId: micId || void 0
}
});
const isSelfieCam = true;
this.video.classList.toggle("mirrored", isSelfieCam);
const aspectRatio = this.cameraStream.getVideoTracks()[0].getSettings().aspectRatio;
if (aspectRatio) {
this.video.style.aspectRatio = `${aspectRatio}`;
} else {
this.video.style.aspectRatio = "";
}
this.video.srcObject = this.cameraStream;
this.video.play();
return {
cameraId: this.cameraStream.getVideoTracks()[0].getSettings().deviceId,
micId: this.cameraStream.getAudioTracks()[0].getSettings().deviceId
};
}
async #initializeMediaInput() {
const savedCamera = window.localStorage.getItem("multimodal-camera");
const savedMic = window.localStorage.getItem("multimodal-mic");
const { cameraId, micId } = await this.setMediaDevices(
savedCamera,
savedMic
);
const devices = await navigator.mediaDevices.enumerateDevices();
this.avSettingsMenu.setCameras(
devices.filter((dev) => dev.kind === "videoinput")
);
this.avSettingsMenu.setMics(
devices.filter((dev) => dev.kind === "audioinput")
);
this.avSettingsMenu.cameraId = cameraId;
this.avSettingsMenu.micId = micId;
const handleDeviceChange = async (deviceType, deviceId) => {
if (!deviceId) return;
window.localStorage.setItem(`multimodal-${deviceType}`, deviceId);
await this.setMediaDevices(
this.avSettingsMenu.cameraId,
this.avSettingsMenu.micId
);
};
this.avSettingsMenu.addEventListener("camera-change", (e) => {
handleDeviceChange("camera", this.avSettingsMenu.cameraId);
});
this.avSettingsMenu.addEventListener("mic-change", (e) => {
handleDeviceChange("mic", this.avSettingsMenu.micId);
});
}
_beginRecord() {
this.recorder = new MediaRecorder(this.cameraStream, {});
this.recorder.addEventListener("error", (e) => {
console.error("MediaRecorder error:", e.error);
});
this.recorder.addEventListener("dataavailable", (e) => {
this.chunks.push(e.data);
});
this.recorder.addEventListener("start", () => {
});
this.recorder.start();
}
_endRecord(emit = true) {
this.recorder.stop();
if (!emit) {
this.chunks = [];
} else {
setTimeout(() => {
const blob = new Blob(this.chunks, { type: this.chunks[0].type });
const event = new BlobEvent("data", {
data: blob
});
try {
this.dispatchEvent(event);
} finally {
this.chunks = [];
}
}, 0);
}
}
};
customElements.define("video-clipper", VideoClipperElement);
// srcts/avSettingsMenu.ts
var DeviceChangeEvent = class extends CustomEvent {
constructor(type, detail) {
super(type, { detail });
}
};
var AVSettingsMenuElement = class extends HTMLElement {
constructor() {
super();
this.addEventListener("click", (e) => {
if (e.target instanceof HTMLAnchorElement) {
const a = e.target;
if (a.classList.contains("camera-device-item")) {
this.cameraId = a.dataset.deviceId;
} else if (a.classList.contains("mic-device-item")) {
this.micId = a.dataset.deviceId;
}
}
});
}
#setDevices(deviceType, devices) {
const deviceEls = devices.map(
(dev) => this.#createDeviceElement(dev, `${deviceType}-device-item`)
);
const header = this.querySelector(`.${deviceType}-header`);
header.after(...deviceEls);
}
setCameras(cameras) {
this.#setDevices("camera", cameras);
}
setMics(mics) {
this.#setDevices("mic", mics);
}
get cameraId() {
return this.#getSelectedDevice("camera");
}
set cameraId(id) {
this.#setSelectedDevice("camera", id);
}
get micId() {
return this.#getSelectedDevice("mic");
}
set micId(id) {
this.#setSelectedDevice("mic", id);
}
#createDeviceElement(dev, className) {
const li = this.ownerDocument.createElement("li");
const a = li.appendChild(this.ownerDocument.createElement("a"));
a.onclick = (e) => e.preventDefault();
a.href = "#";
a.textContent = dev.label;
a.dataset.deviceId = dev.deviceId;
a.className = className;
return li;
}
#getSelectedDevice(device) {
return this.querySelector(
`a.${device}-device-item.active`
)?.dataset.deviceId ?? null;
}
#setSelectedDevice(device, id) {
this.querySelectorAll(`a.${device}-device-item.active`).forEach(
(a) => a.classList.remove("active")
);
if (id) {
this.querySelector(
`a.${device}-device-item[data-device-id="${id}"]`
).classList.add("active");
}
this.dispatchEvent(
new DeviceChangeEvent(`${device}-change`, {
deviceId: id
})
);
}
};
customElements.define("av-settings-menu", AVSettingsMenuElement);
// srcts/audioSpinner.ts
var AudioSpinnerElement = class extends HTMLElement {
#audio;
#canvas;
#ctx2d;
#analyzer;
#dataArray;
#smoother;
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: relative;
}
::slotted(canvas) {
position: absolute;
top: 0;
left: 0;
}
::slotted(audio) {
display: none;
}
</style>
<slot name="audio"></slot>
<slot name="canvas"></slot>
`;
}
connectedCallback() {
const audioSlot = this.shadowRoot.querySelector(
"slot[name=audio]"
);
this.#audio = this.ownerDocument.createElement("audio");
this.#audio.autoplay = true;
this.#audio.controls = false;
this.#audio.src = this.getAttribute("src");
this.#audio.slot = "audio";
audioSlot.assign(this.#audio);
this.#audio.addEventListener("play", () => {
this.#draw();
});
this.#audio.onpause = () => {
this.style.transition = "opacity 0.5s 1s";
this.classList.add("fade");
this.addEventListener("transitionend", () => {
this.remove();
});
};
const canvasSlot = this.shadowRoot.querySelector(
"slot[name=canvas]"
);
this.#canvas = this.ownerDocument.createElement("canvas");
this.#canvas.slot = "canvas";
this.#canvas.width = this.clientWidth * window.devicePixelRatio;
this.#canvas.height = this.clientHeight * window.devicePixelRatio;
this.#canvas.style.width = this.clientWidth + "px";
this.#canvas.style.height = this.clientHeight + "px";
this.appendChild(this.#canvas);
canvasSlot.assign(this.#canvas);
this.#ctx2d = this.#canvas.getContext("2d");
this.#ctx2d.scale(window.devicePixelRatio, window.devicePixelRatio);
new ResizeObserver(() => {
this.#canvas.width = this.clientWidth;
this.#canvas.height = this.clientHeight;
}).observe(this);
const audioCtx = new AudioContext();
const source = audioCtx.createMediaElementSource(this.#audio);
this.#analyzer = new AnalyserNode(audioCtx, {
fftSize: 2048
});
this.#dataArray = new Float32Array(this.#analyzer.frequencyBinCount);
source.connect(this.#analyzer);
this.#analyzer.connect(audioCtx.destination);
const dataArray2 = new Float32Array(this.#analyzer.frequencyBinCount);
this.#smoother = new Smoother(5, (samples) => {
for (let i = 0; i < dataArray2.length; i++) {
dataArray2[i] = 0;
for (let j = 0; j < samples.length; j++) {
dataArray2[i] += samples[j][i];
}
dataArray2[i] /= samples.length;
}
return dataArray2;
});
this.#draw();
}
#draw() {
if (!this.isConnected) {
return;
}
requestAnimationFrame(() => this.#draw());
const width = this.#canvas.width;
const height = this.#canvas.height;
this.#ctx2d.clearRect(0, 0, width, height);
this.#analyzer.getFloatTimeDomainData(this.#dataArray);
const smoothed = this.#smoother.add(new Float32Array(this.#dataArray));
const {
spinVelocity,
gap,
thickness,
minRadius,
radiusFactor,
steps,
blades
} = this.#getSettings(width, height);
const avg = smoothed.reduce((a, b) => a + Math.abs(b), 0) / smoothed.length * 4;
const radius = minRadius + avg * (height - minRadius) / radiusFactor;
for (let step = 0; step < steps; step++) {
const this_radius = radius - step * (radius / (steps + 1));
if (step === steps - 1) {
this.#drawPie(width, height, 0, Math.PI * 2, this_radius, thickness);
} else {
const seconds = (/* @__PURE__ */ new Date()).getTime() / 1e3;
const startAngle = seconds * spinVelocity % (Math.PI * 2);
for (let blade = 0; blade < blades; blade++) {
const angleOffset = Math.PI * 2 / blades * blade;
const sweep = Math.PI * 2 / blades - gap;
this.#drawPie(
width,
height,
startAngle + angleOffset,
sweep,
this_radius,
thickness
);
}
}
}
}
#drawPie(width, height, startAngle, sweep, radius, thickness) {
this.#ctx2d.beginPath();
this.#ctx2d.fillStyle = this.#canvas.computedStyleMap().get("color")?.toString();
if (!thickness) {
this.#ctx2d.moveTo(width / 2, height / 2);
}
this.#ctx2d.arc(
width / 2,
height / 2,
radius,
startAngle,
startAngle + sweep
);
if (!thickness) {
this.#ctx2d.lineTo(width / 2, height / 2);
} else {
this.#ctx2d.arc(
width / 2,
height / 2,
radius - thickness,
startAngle + sweep,
startAngle,
true
);
}
this.#ctx2d.fill();
}
#getSettings(width, height) {
const settings = {
spinVelocity: 5,
gap: Math.PI / 5,
thickness: 2.5,
minRadius: Math.min(width, height) / 6,
radiusFactor: 1.8,
steps: 3,
blades: 3
};
for (const key in settings) {
const value = tryParseFloat(this.dataset[key]);
if (typeof value !== "undefined") {
Object.assign(settings, { [key]: value });
}
}
return settings;
}
};
window.customElements.define("audio-spinner", AudioSpinnerElement);
var Smoother = class {
#samples = [];
#smooth;
#size;
#pos;
constructor(size, smooth) {
this.#size = size;
this.#pos = 0;
this.#smooth = smooth;
}
add(sample) {
this.#samples[this.#pos] = sample;
this.#pos = (this.#pos + 1) % this.#size;
return this.smoothed();
}
smoothed() {
return this.#smooth(this.#samples);
}
};
function tryParseFloat(str) {
if (typeof str === "undefined") {
return void 0;
}
const parsed = parseFloat(str);
return isNaN(parsed) ? void 0 : parsed;
}
// srcts/index.ts
var VideoClipperBinding = class extends Shiny.InputBinding {
#lastKnownValue = /* @__PURE__ */ new WeakMap();
#handlers = /* @__PURE__ */ new WeakMap();
find(scope) {
return $(scope).find("video-clipper");
}
getValue(el) {
return this.#lastKnownValue.get(el);
}
subscribe(el, callback) {
const handler = async (ev) => {
const blob = ev.data;
this.#lastKnownValue.set(el, {
type: blob.type,
bytes: await base64(blob)
});
callback(true);
};
el.addEventListener("data", handler);
this.#handlers.set(el, handler);
}
unsubscribe(el) {
const handler = this.#handlers.get(el);
el.removeEventListener("data", handler);
this.#handlers.delete(el);
}
};
window.Shiny.inputBindings.register(new VideoClipperBinding(), "video-clipper");
async function base64(blob) {
const buf = await blob.arrayBuffer();
const results = [];
const CHUNKSIZE = 1024;
for (let i = 0; i < buf.byteLength; i += CHUNKSIZE) {
const chunk = buf.slice(i, i + CHUNKSIZE);
results.push(String.fromCharCode(...new Uint8Array(chunk)));
}
return btoa(results.join(""));
}