142 lines
3.8 KiB
TypeScript
142 lines
3.8 KiB
TypeScript
"use client";
|
|
|
|
import { LockKeyhole, Pause, Play } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
|
import styles from "./voice-bubble.module.css";
|
|
|
|
export interface VoiceBubbleProps {
|
|
messageId?: string;
|
|
audioUrl?: string | null;
|
|
isFromAI: boolean;
|
|
locked?: boolean;
|
|
hint?: string | null;
|
|
isUnlocking?: boolean;
|
|
onUnlock?: () => void;
|
|
}
|
|
|
|
export function VoiceBubble({
|
|
messageId,
|
|
audioUrl,
|
|
isFromAI,
|
|
locked = false,
|
|
hint,
|
|
isUnlocking = false,
|
|
onUnlock,
|
|
}: VoiceBubbleProps) {
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [errorMediaUrl, setErrorMediaUrl] = useState<string | null>(null);
|
|
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
|
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
|
useCachedChatMediaUrl({
|
|
messageId,
|
|
remoteUrl: audioUrl,
|
|
kind: "audio",
|
|
});
|
|
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
const handleEnded = () => setIsPlaying(false);
|
|
const handlePause = () => setIsPlaying(false);
|
|
const handlePlay = () => setIsPlaying(true);
|
|
const handleError = () => {
|
|
if (isUsingCachedMedia) {
|
|
reportMediaError();
|
|
return;
|
|
}
|
|
setErrorMediaUrl(mediaUrl);
|
|
setIsPlaying(false);
|
|
};
|
|
|
|
audio.addEventListener("ended", handleEnded);
|
|
audio.addEventListener("pause", handlePause);
|
|
audio.addEventListener("play", handlePlay);
|
|
audio.addEventListener("error", handleError);
|
|
return () => {
|
|
audio.removeEventListener("ended", handleEnded);
|
|
audio.removeEventListener("pause", handlePause);
|
|
audio.removeEventListener("play", handlePlay);
|
|
audio.removeEventListener("error", handleError);
|
|
};
|
|
}, [isUsingCachedMedia, mediaUrl, reportMediaError]);
|
|
|
|
const hasError = errorMediaUrl === mediaUrl;
|
|
|
|
const handleToggle = () => {
|
|
if (locked || !hasAudio) return;
|
|
|
|
const audio = audioRef.current;
|
|
if (!audio || hasError) return;
|
|
|
|
if (isPlaying) {
|
|
audio.pause();
|
|
return;
|
|
}
|
|
|
|
void audio.play().catch(() => {
|
|
setErrorMediaUrl(mediaUrl);
|
|
setIsPlaying(false);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`${styles.bubble} ${
|
|
isFromAI ? styles.bubbleAi : styles.bubbleUser
|
|
} ${locked ? styles.locked : ""}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={styles.playButton}
|
|
onClick={handleToggle}
|
|
disabled={locked || hasError || !hasAudio}
|
|
aria-label={isPlaying ? "Pause voice message" : "Play voice message"}
|
|
>
|
|
{locked ? (
|
|
<LockKeyhole size={18} />
|
|
) : isPlaying ? (
|
|
<Pause size={18} />
|
|
) : (
|
|
<Play size={18} />
|
|
)}
|
|
</button>
|
|
<div className={styles.body}>
|
|
<div className={styles.wave} aria-hidden="true">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
{hasError ? (
|
|
<p className={styles.error}>Voice message failed to load.</p>
|
|
) : null}
|
|
{locked ? (
|
|
<>
|
|
<p className={styles.hint}>
|
|
{hint && hint.length > 0
|
|
? hint
|
|
: "Elio has a locked voice message for you."}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className={styles.unlockButton}
|
|
disabled={isUnlocking || !onUnlock}
|
|
onClick={onUnlock}
|
|
>
|
|
{isUnlocking ? "Unlocking..." : "Unlock voice message"}
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
{!locked && hasAudio ? (
|
|
<audio ref={audioRef} src={mediaUrl} preload="metadata" />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|