Spaces:
Runtime error
Runtime error
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 = ` | |
<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]" | |
)! 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); | |