Files
beavergram/frontend/src/lib/components/media/VoiceMessage.svelte
T
2026-06-02 01:02:08 +02:00

276 lines
6.0 KiB
Svelte

<script lang="ts">
import { requestTranscription } from "$lib/api/media";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatDuration } from "$lib/format/duration";
import { claimPlayback, releasePlayback } from "$lib/media/playback";
import { computeWaveform, flatWaveform } from "$lib/media/waveform";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
chatId: number;
messageId: number;
own: boolean;
transcript?: string | null;
url: string;
}
let { url, own, transcript = null, chatId, messageId }: Props = $props();
let fetched = $state<string | null>(null);
let pending = $state(false);
const text = $derived(fetched ?? transcript);
let showTranscript = $state(false);
async function onTranscribe() {
if (text) {
showTranscript = !showTranscript;
return;
}
if (pending) {
return;
}
pending = true;
try {
const result = await requestTranscription(chatId, messageId);
if (result) {
fetched = result;
showTranscript = true;
} else {
toasts.error("Не удалось расшифровать");
}
} catch {
toasts.error("Не удалось расшифровать");
} finally {
pending = false;
}
}
let element = $state<HTMLAudioElement>();
let peaks = $state<number[]>(flatWaveform());
let currentTime = $state(0);
let duration = $state(0);
let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0);
const elapsed = $derived(
paused && currentTime === 0 ? duration : currentTime
);
$effect(() => {
let active = true;
computeWaveform(url)
.then((result) => {
if (active) {
peaks = result;
}
})
.catch(() => undefined);
return () => {
active = false;
};
});
function toggle() {
if (!element) {
return;
}
if (paused) {
element.play().catch(() => undefined);
} else {
element.pause();
}
}
function seek(event: PointerEvent) {
if (!(element && duration > 0)) {
return;
}
const rect = event.currentTarget as HTMLElement;
const ratio =
(event.clientX - rect.getBoundingClientRect().left) / rect.offsetWidth;
element.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
}
</script>
<div class="VoiceWrap" class:own>
<div class="Voice">
<button
class="toggle"
onclick={toggle}
type="button"
aria-label="Play voice"
>
<Icon
name={paused ? "play" : "pause"}
size="1.5rem"
class={paused ? "nudge" : ""}
/>
</button>
<div class="body">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="waveform" onpointerdown={seek}>
{#each peaks as peak, i (i)}
<span
class="bar"
class:filled={i / peaks.length < progress}
style:height={`${Math.round(peak * 100)}%`}
></span>
{/each}
</div>
<div class="time">{formatDuration(elapsed)}</div>
</div>
<button
class="transcribe"
class:active={showTranscript && Boolean(text)}
disabled={pending}
onclick={onTranscribe}
type="button"
aria-label="Show transcription"
>
{#if pending}
<Spinner color={own ? "white" : "gray"} size="1rem" />
{:else}
<Icon name="transcribe" size="1.125rem" />
{/if}
</button>
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
<audio
bind:this={element}
bind:currentTime
bind:duration
bind:paused
onplay={() => element && claimPlayback(element)}
onended={() => element && releasePlayback(element)}
preload="metadata"
src={url}
></audio>
</div>
{#if text && showTranscript}
<div class="transcript">{text}</div>
{/if}
</div>
<style lang="scss">
.VoiceWrap {
--active: var(--color-primary);
--inactive: var(--color-interactive-inactive);
--toggle-fg: var(--color-white);
--voice-meta: var(--color-text-secondary);
--transcribe-bg: var(--color-primary-tint);
&.own {
--active: var(--color-white);
--inactive: rgba(255, 255, 255, 0.45);
--toggle-fg: var(--color-background-own);
--voice-meta: rgba(255, 255, 255, 0.75);
--transcribe-bg: rgba(255, 255, 255, 0.2);
}
}
.Voice {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 13rem;
padding: 0.1875rem 0;
}
.transcribe {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border: 0;
border-radius: 50%;
color: var(--voice-meta);
background-color: var(--transcribe-bg);
&.active {
color: var(--toggle-fg);
background-color: var(--active);
}
&:disabled {
cursor: default;
}
}
.transcript {
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px solid var(--color-borders);
font-size: 0.9375rem;
line-height: 1.3;
color: var(--color-text);
white-space: pre-wrap;
}
.toggle {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
color: var(--toggle-fg);
background-color: var(--active);
:global(.nudge) {
transform: translateX(0.0625rem);
}
}
.body {
flex: 1;
min-width: 0;
}
.waveform {
cursor: pointer;
touch-action: none;
display: flex;
align-items: flex-end;
gap: 1px;
height: 23px;
}
.bar {
flex: 1;
min-height: 2px;
border-radius: 1px;
background-color: var(--inactive);
&.filled {
background-color: var(--active);
}
}
.time {
margin-top: 0.25rem;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--voice-meta);
}
</style>