class VideoClipperElement extends HTMLElement { video: HTMLVideoElement; avSettingsMenu!: AVSettingsMenuElement; buttonRecord!: HTMLButtonElement; buttonStop!: HTMLButtonElement; cameraStream?: MediaStream; micStream?: MediaStream; recorder?: MediaRecorder; chunks: Blob[] = []; constructor() { super(); this.attachShadow({ mode: "open" }); this.shadowRoot!.innerHTML = `
`; this.video = this.shadowRoot!.querySelector("video")!; } connectedCallback() { (async () => { const slotSettings = this.shadowRoot!.querySelector( "slot[name=settings]" )! as HTMLSlotElement; slotSettings.addEventListener("slotchange", async () => { this.avSettingsMenu = slotSettings.assignedElements()[0] as AVSettingsMenuElement; await this.#initializeMediaInput(); if (this.buttonRecord) { this.#setEnabledButton(this.buttonRecord); } }); const slotControls = this.shadowRoot!.querySelector( "slot[name=recording-controls]" )! as HTMLSlotElement; slotControls.addEventListener("slotchange", () => { const findButton = (selector: string): HTMLElement | null => { for (const el of slotControls.assignedElements()) { if (el.matches(selector)) { return el as HTMLElement; } const sub = el.querySelector(selector); if (sub) { return sub as HTMLElement; } } return null; }; this.buttonRecord = findButton(".record-button")! as HTMLButtonElement; this.buttonStop = findButton(".stop-button")! as HTMLButtonElement; 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?: HTMLButtonElement) { this.buttonRecord.style.display = btn === this.buttonRecord ? "inline-block" : "none"; this.buttonStop.style.display = btn === this.buttonStop ? "inline-block" : "none"; } async setMediaDevices( cameraId: string | null, micId: string | null ): Promise<{ cameraId: string; micId: string }> { if (this.cameraStream) { this.cameraStream.getTracks().forEach((track) => track.stop()); } this.cameraStream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: cameraId || undefined, facingMode: "user", aspectRatio: 16 / 9, }, audio: { deviceId: micId || undefined, }, }); // TODO: I can't figure out how to tell if this is actually a selfie cam. // Ideally we wouldn't mirror unless we are sure. const isSelfieCam = true; // this.cameraStream.getVideoTracks()[0].getSettings().facingMode === "user"; this.video.classList.toggle("mirrored", isSelfieCam); /* Prevent the height from jumping around when switching cameras */ 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() { // Retrieve the user's previous camera and mic settings, if they ever // explicitly chose one const savedCamera = window.localStorage.getItem("multimodal-camera"); const savedMic = window.localStorage.getItem("multimodal-mic"); // Initialize the camera and mic with the saved settings. It's important to // request camera/mic access _before_ we attempt to enumerate devices, // because if the user has not granted camera/mic access, enumerateDevices() // will not prompt the user for permission and will instead return empty // devices. // // The return values are the actual camera and mic IDs that were used, which // may be different from the saved values if those devices are no longer // available. const { cameraId, micId } = await this.setMediaDevices( savedCamera, savedMic ); // Populate the camera and mic dropdowns with the available devices const devices = await navigator.mediaDevices.enumerateDevices(); this.avSettingsMenu.setCameras( devices.filter((dev) => dev.kind === "videoinput") ); this.avSettingsMenu.setMics( devices.filter((dev) => dev.kind === "audioinput") ); // Update the dropdown UI to reflect the actual devices that were used this.avSettingsMenu.cameraId = cameraId; this.avSettingsMenu.micId = micId; // Listen for changes to the camera and mic dropdowns const handleDeviceChange = async ( deviceType: string, deviceId: string | null ) => { 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() { // Create a MediaRecorder object this.recorder = new MediaRecorder(this.cameraStream!, {}); this.recorder.addEventListener("error", (e) => { console.error("MediaRecorder error:", (e as ErrorEvent).error); }); this.recorder.addEventListener("dataavailable", (e) => { // console.log("chunk: ", e.data.size, e.data.type); this.chunks.push(e.data); }); this.recorder.addEventListener("start", () => { // console.log("Recording started"); }); this.recorder.start(); } _endRecord(emit: boolean = true) { this.recorder!.stop(); if (!emit) { this.chunks = []; } else { // Use setTimeout to give it a moment to finish processing the last chunk setTimeout(() => { // console.log("chunks: ", this.chunks.length); const blob = new Blob(this.chunks, { type: this.chunks[0].type }); // emit blobevent const event = new BlobEvent("data", { data: blob, }); try { this.dispatchEvent(event); } finally { this.chunks = []; } }, 0); } } } customElements.define("video-clipper", VideoClipperElement);