@spitch/reader
Version:
Embeddable audio reader component to render a rich audio reader player in African languages.
864 lines (855 loc) • 25.5 kB
JavaScript
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"
="${(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"
="${() => 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}"
="${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}"
="${() => this.seek(-10)}"
title="Rewind"
>
${rewindSVG}
</button>
<span
class="speed"
="${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}"
="${() => 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;
}
spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
(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;
}
}
(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>`;