import {html, css, PropertyValueMap} from "lit" import {LitElementWw} from "@webwriter/lit" import {customElement, property, query} from "lit/decorators.js" import "@shoelace-style/shoelace/dist/themes/light.css" import IconMic from "bootstrap-icons/icons/mic.svg" import IconMicFill from "bootstrap-icons/icons/mic-fill.svg" import IconStopFill from "bootstrap-icons/icons/stop-fill.svg" import IconTrash from "bootstrap-icons/icons/trash.svg" import IconPlay from "bootstrap-icons/icons/play.svg" import IconPause from "bootstrap-icons/icons/pause.svg" import IconVolumeDown from "bootstrap-icons/icons/volume-down.svg" import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js" import SlRange from "@shoelace-style/shoelace/dist/components/range/range.component.js" import SlButtonGroup from "@shoelace-style/shoelace/dist/components/button-group/button-group.component.js" import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js" import SlFormatDate from "@shoelace-style/shoelace/dist/components/format-date/format-date.component.js" import SlPopup from "@shoelace-style/shoelace/dist/components/popup/popup.component.js" declare global {interface HTMLElementTagNameMap { "webwriter-speech": WebwriterSpeech; }} import LOCALIZE from "../../localization/generated" import {msg} from "@lit/localize" @customElement("webwriter-speech") export class WebwriterSpeech extends LitElementWw { localize = LOCALIZE static scopedElements = { "sl-button": SlButton, "sl-icon": SlIcon, "sl-button-group": SlButtonGroup, "sl-range": SlRange, "sl-format-date": SlFormatDate, "sl-popup": SlPopup } static styles = css` @keyframes blinker { 50% { opacity: 0; } } sl-icon { height: 20px; width: 20px; } sl-button-group { width: 100%; } #record { position: relative; #recording-indicator { background: darkred; border-radius: 100%; height: 10px; width: 10px; position: absolute; top: 5px; right: 5px; animation: blinker 1.25s linear infinite; } } #record:hover .idle { display: none; } :host([recording]) #record .torecord, :host(:not([recording])) #record:not(:hover) .torecord { display: none; } :host(:not([recording])) #record .tostop, :host([recording]) #record:not(:hover) .tostop { display: none; } :host(:not([recording])) #recording-indicator { display: none; } #playback { width: 100%; display: flex; flex-direction: row; align-items: center; border-top: 1px solid var(--sl-color-gray-300); border-bottom: 1px solid var(--sl-color-gray-300); padding: 0 1ch; gap: 1ch; color: var(--sl-color-gray-800); font-size: var(--sl-font-size-small); user-select: none; sl-range { flex-grow: 1; display: flex; flex-direction: row; align-items: center; &::part(form-control) { width: 100%; } } } :host(:not([src])) #playback { opacity: 0.5; } sl-button { &::part(label) { display: flex; flex-direction: row; align-items: center; gap: 1ch; } } sl-tooltip { z-index: 1; } #volume { --arrow-color: var(--sl-color-gray-600); &::part(popup) { background: var(--arrow-color); height: 30px; padding: 1ch; } } ` mediaRecorder: MediaRecorder chunks = [] as Blob[] @property({type: Boolean, attribute: true}) accessor loading = false @property({type: String, attribute: true, reflect: true}) accessor src: string firstUpdated() { const evs = ["abort", "canplay", "canplaythrough", "durationchange", "emptied", "ended", "error", "loadeddata", "loadedmetadata", "loadstart", "pause", "play", "playing", "progress", "ratechange", "resize", "seeked", "seeking", "stalled", "suspend", "timeupdate", "volumechange", "waiting"] as const evs.forEach(k => this.audioEl.addEventListener(k, ()=>this.requestUpdate())) } connectedCallback(): void { super.connectedCallback() } async initializeRecorder() { if(!this.mediaRecorder) { this.loading = true try { const stream = await navigator.mediaDevices.getUserMedia({audio: true}) this.mediaRecorder = new MediaRecorder(stream) this.mediaRecorder.addEventListener("start", e => { this.requestUpdate("recording") }) this.mediaRecorder.addEventListener("dataavailable", e => { this.chunks.push(e.data) }) this.mediaRecorder.addEventListener("stop", async e => { const blob = new Blob(this.chunks, {type: "audio/ogg; codecs=opus"}) this.chunks = [] this.src = URL.createObjectURL(blob) const reader = new FileReader() reader.readAsDataURL(blob) reader.addEventListener("load", e => { this.src = reader.result as string this.requestUpdate("recording") }) }) } finally { this.loading = false } } } toggleRecording = async () => { if(this.mediaRecorder?.state === "recording") { this.mediaRecorder.stop() this.mediaRecorder.stream.getTracks().forEach(track => track.stop()) } else { await this.initializeRecorder() this.mediaRecorder.start() } } @query("audio") accessor audioEl: HTMLAudioElement get duration() { const d = this.audioEl?.duration return !Number.isNaN(d) && Number.isFinite(d)? d: 0 } get currentTime() { return this.audioEl?.currentTime } get playing() { return !this.audioEl?.paused && !this.audioEl?.ended } get recording() { return this.mediaRecorder?.state === "recording" } @property({type: Boolean, attribute: true, reflect: true}) set recording(value: boolean) { return } get volume() { return Math.round(this.audioEl?.volume * 100) } set volume(value: number) { this.audioEl.volume = value / 100 } togglePlay = () => { !this.playing? this.audioEl.play(): this.audioEl.pause() } @property({type: Boolean}) accessor volumeOpen = false render() { return html`
/
this.volumeOpen = !this.volumeOpen}> this.volume = e.target.value}> this.src = undefined}>
` } }