File size: 8,215 Bytes
21e6506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
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);