UNPKG

@spitch/reader

Version:

Embeddable audio reader component to render a rich audio reader player in African languages.

864 lines (855 loc) 25.5 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { LitElement, html, css } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { Howl } from "howler"; import { spitchAPI } from "./services/spitch-api.js"; import { VOICES } from "./constants/voices"; let AudioReader = class AudioReader extends LitElement { constructor() { super(...arguments); this.readerId = ""; this.config = null; this.isPlaying = false; this.currentTime = 0; this.duration = 0; this.playbackRate = 1.0; this.isLoading = false; this.error = ""; this.howl = null; this.progressInterval = null; this.speedOptions = [0.5, 1.0, 1.25, 1.5, 2.0]; this.showDropdown = false; this.handleDropdownToggle = (e) => { e.stopPropagation(); this.showDropdown = !this.showDropdown; this.requestUpdate(); if (this.showDropdown) { document.addEventListener('click', this.handleOutsideClick, true); } else { document.removeEventListener('click', this.handleOutsideClick, true); } }; this.handleOutsideClick = (e) => { if (!this.shadowRoot?.querySelector('.voice-dropdown-container')?.contains(e.target)) { this.showDropdown = false; this.requestUpdate(); document.removeEventListener('click', this.handleOutsideClick, true); } }; } connectedCallback() { super.connectedCallback(); this.fetchConfig(); } disconnectedCallback() { super.disconnectedCallback(); this.cleanupHowl(); } updated(changed) { if (changed.has("readerId")) { this.fetchConfig(); } } async fetchConfig() { this.cleanupHowl(); if (!this.readerId) return; try { const apiConfig = await spitchAPI.getReaderConfig(this.readerId); this.config = { id: this.readerId, name: apiConfig.voices && apiConfig.voices[0] ? apiConfig.voices[0].charAt(0).toUpperCase() + apiConfig.voices[0].slice(1) : '', audioUrl: '', avatarUrl: apiConfig.voices && apiConfig.voices[0] ? `https://www.spitch.studio/avatars/${apiConfig.voices[0].charAt(0).toUpperCase() + apiConfig.voices[0].slice(1)}.png` : '', appearance: { backgroundColor: apiConfig.bg_color || '#fff', textColor: apiConfig.text_color || '#0a0a0a', }, language: apiConfig.languages && apiConfig.languages[0] ? apiConfig.languages[0] : '', voice: apiConfig.voices && apiConfig.voices[0] ? apiConfig.voices[0] : '', url: undefined, urls: apiConfig.urls || [], org: apiConfig.org || '', origin: apiConfig.origin || '', }; const allowedUrls = apiConfig.urls || []; const currentOrigin = window.location.origin; const isAllowed = allowedUrls.some((u) => { try { return new URL(u).origin === currentOrigin; } catch { return false; } }); if (!isAllowed) { this.error = 'Audio playback is not allowed on this website. Please contact the owner to add this site to the allowlist.'; this.config.url = undefined; } else { this.config.url = window.location.href; this.error = ''; } this.currentTime = 0; this.isPlaying = false; this.duration = 0; this.requestUpdate(); console.log("Config fetched:", this.config); } catch (e) { this.error = 'Failed to load reader config.'; this.config = null; } } async playStreamingAudio() { if (!this.config || !this.config.url) { this.error = this.error || 'Audio playback is not allowed on this website.'; this.requestUpdate(); return; } this.isLoading = true; this.error = ""; try { const response = await spitchAPI.generateStreamingAudio({ url: this.config.url, language: this.config.language, voice: this.config.voice, id: this.config.id, origin: this.config.origin, }); const contentType = response.headers.get("Content-Type") || ""; if (contentType.includes("audio/mpeg")) { // Streaming MP3 const mediaSource = new MediaSource(); const audioUrl = URL.createObjectURL(mediaSource); if (this.config) { this.config.audioUrl = audioUrl; } this.initHowl(); mediaSource.addEventListener("sourceopen", async () => { const sourceBuffer = mediaSource.addSourceBuffer("audio/mpeg"); const reader = response.body.getReader(); let appendBuffer = async (value) => { return new Promise((resolve, _reject) => { sourceBuffer.addEventListener("updateend", () => resolve(), { once: true, }); sourceBuffer.appendBuffer(value.slice().buffer); }); }; while (true) { const { done, value } = await reader.read(); if (done) break; await appendBuffer(value); } mediaSource.endOfStream(); }); this.howl?.play(); } else if (contentType.includes("audio/wav")) { // Fallback: Download full WAV and play after download const audioBlob = await response.blob(); const audioUrl = URL.createObjectURL(audioBlob); if (this.config) { this.config.audioUrl = audioUrl; } this.initHowl(); this.howl?.play(); } else { this.error = "Unsupported audio format. Please contact support."; console.error("Unsupported Content-Type:", contentType); } } catch (error) { this.error = "Failed to stream audio."; console.error(error); } finally { this.isLoading = false; } } initHowl() { if (!this.config) return; this.cleanupHowl(); this.howl = new Howl({ src: [this.config.audioUrl], html5: true, preload: true, onload: () => { this.duration = this.howl?.duration() || 0; if (this.duration > 0) { this.error = ""; } this.requestUpdate(); }, onplay: () => { this.isPlaying = true; this.error = ""; this.startProgressInterval(); }, onpause: () => { this.isPlaying = false; this.stopProgressInterval(); }, onend: () => { this.isPlaying = false; this.currentTime = 0; this.stopProgressInterval(); this.requestUpdate(); }, onstop: () => { this.isPlaying = false; this.stopProgressInterval(); this.currentTime = 0; this.requestUpdate(); }, onseek: () => { this.currentTime = this.howl?.seek() || 0; this.requestUpdate(); }, onloaderror: (_id, _err) => { if (!this.duration) { this.error = "Failed to load audio. Please try again."; } }, onplayerror: (_id, _err) => { if (!this.duration) { this.error = "Failed to play audio. Please try again."; } }, }); } cleanupHowl() { if (this.howl) { this.howl.unload(); this.howl = null; } this.stopProgressInterval(); } async togglePlay() { if (!this.config) return; // If we don't have audio yet, stream it first if (!this.howl && this.config.url) { await this.playStreamingAudio(); return; } if (!this.howl) return; if (this.isPlaying) { this.howl.pause(); } else { this.howl.play(); } } startProgressInterval() { this.stopProgressInterval(); this.progressInterval = setInterval(() => { if (this.howl && this.isPlaying) { this.currentTime = this.howl.seek(); this.requestUpdate(); } }, 200); } stopProgressInterval() { if (this.progressInterval) { clearInterval(this.progressInterval); this.progressInterval = null; } } seek(seconds) { if (this.howl) { let newTime = this.howl.seek() + seconds; newTime = Math.max(0, Math.min(newTime, this.duration)); this.howl.seek(newTime); this.currentTime = newTime; this.requestUpdate(); } } formatTime(sec) { const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return `${m}:${s.toString().padStart(2, "0")}`; } cycleSpeed() { const currentIdx = this.speedOptions.indexOf(this.playbackRate); const nextIdx = (currentIdx + 1) % this.speedOptions.length; this.playbackRate = this.speedOptions[nextIdx]; if (this.howl) { this.howl.rate(this.playbackRate); } this.requestUpdate(); } async selectVoice(voice) { if (!this.config) return; this.config.voice = voice.value; this.config.avatarUrl = voice.avatar; this.config.name = voice.label; this.showDropdown = false; this.requestUpdate(); await this.playStreamingAudio(); } render() { if (!this.config) return html ` <div class="powered-by-spitch"> Powered by <a href="https://spitch.app" target="_blank" rel="noopener noreferrer">Spitch</a> </div> `; const { appearance, language, voice } = this.config; const voicesForLang = VOICES.filter((v) => v.language === language); const selectedVoice = voicesForLang.find((v) => v.value === voice) || voicesForLang[0]; const progressPercent = this.duration ? (this.currentTime / this.duration) * 100 : 0; return html ` <div class="container" style="background:${appearance.backgroundColor};color:${appearance.textColor}" > <div class="header"> <a class="title audio-link" href="https://spitch.app" target="_blank" rel="noopener noreferrer" >Spitch – Audio</a > <div class="voice-dropdown-container" @click="${(e) => this.handleDropdownToggle(e)}"> <img class="avatar" src="${selectedVoice.avatar}" alt="${selectedVoice.label}" /> <span class="username">${selectedVoice.label}</span> <div class="voice-dropdown" style="display:${this.showDropdown ? "block" : "none"}" > ${voicesForLang.map((v) => v.value !== selectedVoice.value ? html ` <div class="voice-option" @click="${() => this.selectVoice(v)}" > <img src="${v.avatar}" class="avatar" /> <span>${v.label}</span> </div> ` : "")} </div> </div> </div> ${this.error ? html ` <div class="error-message" style="color: #dc2626; font-size: 12px; margin: 2px 0 4px 0; text-align: center;" > ${this.error} </div> ` : ""} <div class="audio-main-row" style="gap: 8px; margin: 4px 0 0 0;"> <button class="icon-btn play-btn" style="color:${appearance.textColor}" @click="${this.togglePlay}" title="Play/Pause" ?disabled="${this.isLoading}" > ${this.isLoading ? loadingSVG : this.isPlaying ? pauseSVG : playSVG} </button> <div class="timeline-group" style="flex:1;"> <div class="progress-bar" style="background:transparent;"> <div class="progress-bg" style="background:${appearance.backgroundColor}; border: 1px solid ${appearance.textColor}" ></div> <div class="progress-fill" style="width:${progressPercent}%;background:${appearance.textColor}" ></div> </div> </div> <div class="audio-info-group"> <span class="time" style="color:${appearance.textColor}" >${this.formatTime(this.currentTime)}/${this.formatTime(this.duration)}</span > <button class="icon-btn volume-btn" style="color:${appearance.textColor}" title="Volume" > ${volumeSVG} </button> </div> </div> <div class="footer" style="gap: 4px; margin-top: 0; font-size: 12px;"> <button class="icon-btn" style="color:${appearance.textColor}" @click="${() => this.seek(-10)}" title="Rewind" > ${rewindSVG} </button> <span class="speed" @click="${this.cycleSpeed}" aria-label="Change playback speed" style="cursor:pointer;color:${appearance.textColor}" title="Change speed" > ${this.playbackRate.toFixed(2).replace(/\.00$/, "")}x </span> <button class="icon-btn" style="color:${appearance.textColor}" @click="${() => this.seek(10)}" title="Fast Forward" > ${fastForwardSVG} </button> </div> ${this.config.text ? html ` <div class="text-preview" style="margin-top: 6px; padding: 6px; background: rgba(0,0,0,0.03); border-radius: 6px; font-size: 10px; max-height: 60px; overflow-y: auto;" > <strong>Text to be read:</strong><br /> ${this.config.text.substring(0, 200)}${this.config.text.length > 200 ? "..." : ""} </div> ` : ""} </div> `; } static { this.styles = css ` :host { display: block; width: 100%; } .container { display: flex; padding: 8px 12px; flex-direction: column; align-items: flex-start; gap: 4px; background: #fff; border: 1px solid #e5e5e5; border-radius: 24px; box-shadow: 0px 1px 2px 0px #1018280d; width: 100%; max-width: 700px; min-width: 220px; box-sizing: border-box; margin: 0 auto; } .header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; width: 100%; margin-bottom: 0; gap: 8px; /* Add gap to prevent overlapping */ } .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: Inter, sans-serif; font-size: 12px; font-style: normal; font-weight: 400; line-height: 20px; color: #0a0a0a; flex: 1 1 auto; /* Allow it to grow but also shrink */ min-width: 0; /* Important for text truncation */ max-width: 120px; } .title.audio-link { text-decoration: underline; color: inherit; cursor: pointer; } .user { display: flex; align-items: center; gap: 8px; font-size: 14px; background: #000230; flex: 0 0 auto; /* Don't shrink */ min-width: fit-content; /* Maintain minimum width needed */ } .voice-dropdown-container { position: relative; display: flex; align-items: center; flex-direction: row; min-width: fit-content; } .avatar { display: flex; width: 20px; height: 20px; flex-direction: column; justify-content: center; align-items: center; border-radius: 50%; object-fit: cover; border: 2px solid #fff; } .username { font-weight: 500; color: #0a0a0a; font-size: 12px; font-family: Inter, sans-serif; } .audio-main-row { display: flex; align-items: center; width: 100%; gap: 8px; margin: 4px 0 0 0; } .play-btn { flex: 0 0 auto; } .play-btn:disabled { opacity: 0.6; cursor: not-allowed; } .timeline-group { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; } .progress-bar { position: relative; width: 100%; height: 6px; margin-bottom: 0; background: transparent; } .progress-bg { position: absolute; width: 100%; height: 8px; border-radius: 8px; z-index: 1; } .progress-fill { position: absolute; left: 0; top: 0; height: 8px; border-radius: 8px; z-index: 2; transition: width 0.2s; } .audio-info-group { flex: 0 0 120px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; } .time { font-family: Inter, sans-serif; font-size: 12px; font-style: normal; font-weight: 500; line-height: 20px; min-width: 80px; text-align: right; letter-spacing: 0.5px; } .volume-btn { margin-left: 4px; } .icon-btn { background: transparent; border: none; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.2s; padding: 0; } .icon-btn svg { width: 20px; height: 20px; } .icon-btn:hover:not(:disabled) { background: rgba(33, 112, 106, 0.08); } .footer { display: flex; justify-content: center; align-items: center; gap: 4px; width: 100%; margin-top: 0; font-size: 12px; } .speed { font-size: 12px; font-family: Inter, sans-serif; font-weight: 600; color: #0a0a0a; } .text-preview { font-family: Inter, sans-serif; line-height: 1.4; margin-top: 6px; padding: 4px; background: rgba(0, 0, 0, 0.03); border-radius: 6px; font-size: 10px; max-height: 60px; overflow-y: auto; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @media (max-width: 700px) { .audio-main-row { flex-direction: column; align-items: stretch; gap: 8px; } .timeline-group, .audio-info-group { flex: 1 1 100%; justify-content: space-between; } .time { text-align: left; } } @media (max-width: 500px) { .container { padding: 8px 4px; max-width: 98vw; border-radius: 16px; } .audio-main-row { gap: 6px; } .icon-btn { width: 28px; height: 28px; } .icon-btn svg { width: 16px; height: 16px; } .avatar { width: 16px; height: 16px; } .title, .username, .speed, .time { font-size: 10px; line-height: 16px; } } .audio-link { text-decoration: underline; color: inherit; cursor: pointer; } .voice-dropdown-container { position: relative; display: flex; align-items: center; flex-direction: row; flex: 0 0 auto; } .voice-dropdown { display: none; position: absolute; top: 120%; right: 0; z-index: 10; background: #fff; border: 1px solid #eee; border-radius: 8px; min-width: 120px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); padding: 4px 0; } .voice-dropdown-container:hover .voice-dropdown, .voice-dropdown-container:focus-within .voice-dropdown { display: block; } .voice-option { display: flex; align-items: center; gap: 8px; padding: 4px 12px; cursor: pointer; font-size: 13px; transition: background 0.15s; } .voice-option:hover { background: #f5f5f5; } .powered-by-spitch { display: flex; align-items: center; justify-content: center; height: 180px; font-size: 18px; color: #888; font-family: Inter, sans-serif; font-weight: 500; letter-spacing: 0.02em; } .powered-by-spitch a { color: #0a0a0a; text-decoration: underline; margin-left: 6px; } `; } }; __decorate([ property({ type: String, attribute: "reader-id" }) ], AudioReader.prototype, "readerId", void 0); __decorate([ state() ], AudioReader.prototype, "config", void 0); __decorate([ state() ], AudioReader.prototype, "isPlaying", void 0); __decorate([ state() ], AudioReader.prototype, "currentTime", void 0); __decorate([ state() ], AudioReader.prototype, "duration", void 0); __decorate([ state() ], AudioReader.prototype, "playbackRate", void 0); __decorate([ state() ], AudioReader.prototype, "isLoading", void 0); __decorate([ state() ], AudioReader.prototype, "error", void 0); AudioReader = __decorate([ customElement("audio-reader") ], AudioReader); export { AudioReader }; // Lucide SVGs const playSVG = html `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <polygon points="5 3 19 12 5 21 5 3"></polygon> </svg>`; const pauseSVG = html `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <rect x="6" y="4" width="4" height="16"></rect> <rect x="14" y="4" width="4" height="16"></rect> </svg>`; const rewindSVG = html `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <polygon points="11 19 2 12 11 5 11 19"></polygon> <polygon points="22 19 13 12 22 5 22 19"></polygon> </svg>`; const fastForwardSVG = html `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <polygon points="13 19 22 12 13 5 13 19"></polygon> <polygon points="2 19 11 12 2 5 2 19"></polygon> </svg>`; const volumeSVG = html `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> <path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path> <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path> </svg>`; const loadingSVG = html `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation: spin 1s linear infinite;" > <circle cx="12" cy="12" r="10"></circle> <path d="m12 2 0 4"></path> </svg>`;