Files
cozsweet-frontend-nextjs/src/app/chat/components/voice-bubble.tsx
T

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>
);
}