Files
beavergram/frontend/src/lib/components/MessageMedia.svelte
T

387 lines
10 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { visible } from "$lib/actions/visible";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia } from "$lib/api/endpoints";
import {
type InlineMedia,
isPreviewable,
loadInlineMedia,
visualKind,
} from "$lib/api/media";
import type { MessageView } from "$lib/api/types";
import AudioFile from "$lib/components/media/AudioFile.svelte";
import FileChip from "$lib/components/media/FileChip.svelte";
import TgsSticker from "$lib/components/media/TgsSticker.svelte";
import VideoNote from "$lib/components/media/VideoNote.svelte";
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
message: MessageView;
onopen: () => void;
own: boolean;
}
let { message, onopen, own }: Props = $props();
const POLL_TRIES = 5;
const POLL_DELAY = 3000;
let loaded = $state(false);
let media = $state<InlineMedia | null>(null);
let queuing = $state(false);
let saving = $state(false);
const ref = $derived(message.media[0] ?? null);
const asFile = $derived(
ref !== null && ref.id !== null && !isPreviewable(ref.kind, ref.mime)
);
const ready = $derived(media?.state === "ready" ? media : null);
const kind = $derived(ready?.kind ?? ref?.kind ?? "");
const mime = $derived(ready?.mime ?? "");
const isImage = $derived(kind === "photo");
const isStaticSticker = $derived(
kind === "sticker" && mime.startsWith("image/")
);
const isVideoSticker = $derived(
kind === "sticker" && mime.startsWith("video/")
);
const isTgsSticker = $derived(
kind === "sticker" && mime === "application/x-tgsticker"
);
const isAnimation = $derived(kind === "animation" || kind === "gif");
const isThumbVideo = $derived(kind === "video");
const vk = $derived(
media && media.state !== "missing" ? visualKind(media.kind) : "other"
);
const label = $derived(
media && media.state !== "missing" ? media.kind : "media"
);
const storedId = $derived(
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
);
const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл");
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function start() {
if (asFile) {
loaded = true;
return;
}
media = await loadInlineMedia(message.chat_id, message.message_id);
loaded = true;
}
async function poll() {
for (let i = 0; i < POLL_TRIES; i++) {
await delay(POLL_DELAY);
const next = await loadInlineMedia(message.chat_id, message.message_id);
if (next.state === "ready") {
media = next;
return;
}
}
}
async function queue() {
if (queuing) {
return;
}
queuing = true;
try {
await fetchMedia(message.chat_id, message.message_id);
toasts.success("Скачивание поставлено в очередь");
poll();
} catch {
toasts.error("Не удалось поставить в очередь");
} finally {
queuing = false;
}
}
async function save() {
if (storedId === null || saving) {
return;
}
saving = true;
try {
await downloadMedia(storedId);
} catch {
toasts.error("Не удалось скачать файл");
} finally {
saving = false;
}
}
function share() {
if (storedId !== null) {
shareUi.share({ kind: "media", mediaId: storedId }, fileName);
}
}
</script>
<ContextMenu>
{#snippet children({ props })}
<div {...props} class="message-media" use:visible={start}>
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Самоуничтожающееся медиа</span>
</button>
{:else if asFile && ref}
<FileChip media={ref} {own} />
{:else if !loaded}
<div class="media-skeleton"><Spinner /></div>
{:else if ready && kind === "voice"}
<VoiceMessage
url={ready.url}
transcript={ready.transcript}
chatId={message.chat_id}
messageId={message.message_id}
{own}
/>
{:else if ready && kind === "video_note"}
<VideoNote url={ready.url} transcript={ready.transcript} />
{:else if ready && kind === "audio"}
<AudioFile url={ready.url} title={fileName} {own} />
{:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button">
<img src={ready.url} alt="attachment">
</button>
{:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button">
<img src={ready.url} alt="sticker">
</button>
{:else if ready && isVideoSticker}
<video
class="media-sticker-video"
src={ready.url}
autoplay
loop
muted
playsinline
></video>
{:else if ready && isTgsSticker}
<TgsSticker url={ready.url} />
{:else if ready && isAnimation}
<button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} autoplay loop muted playsinline></video>
<span class="gif-badge">GIF</span>
</button>
{:else if ready && isThumbVideo}
<button class="media-thumb" onclick={onopen} type="button">
<video
src={ready.url}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
</button>
{:else if ready}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="document" size="1.25rem" />
<span>{label}</span>
</button>
{:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Видео" : "Фото"}</span>
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small>
</button>
{:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Медиа</span>
</button>
{/if}
</div>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem
>
<ContextMenuItem
icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
>
{#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}>
Скачать в архив
</ContextMenuItem>
{:else}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
<style lang="scss">
.message-media {
margin-bottom: 0.25rem;
}
.media-thumb {
cursor: pointer;
position: relative;
display: block;
overflow: hidden;
max-width: 100%;
padding: 0;
border: 0;
border-radius: var(--border-radius-default-small);
background-color: var(--color-default-shadow);
img,
video {
display: block;
width: 100%;
max-width: 20rem;
max-height: 20rem;
object-fit: cover;
}
}
.media-sticker {
cursor: pointer;
display: block;
padding: 0;
border: 0;
background: transparent;
img {
display: block;
width: 12rem;
height: 12rem;
object-fit: contain;
}
}
.media-sticker-video {
display: block;
width: 12rem;
height: 12rem;
object-fit: contain;
}
.gif-badge {
position: absolute;
top: 0.375rem;
left: 0.375rem;
padding: 0.0625rem 0.3125rem;
border-radius: 0.5rem;
font-size: 0.6875rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
background-color: rgba(0, 0, 0, 0.45);
}
.play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
.media-skeleton {
display: flex;
align-items: center;
justify-content: center;
width: 12rem;
height: 9rem;
border-radius: var(--border-radius-default-small);
background-color: var(--color-default-shadow);
}
.media-placeholder {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
width: 12rem;
height: 9rem;
border: 0;
border-radius: var(--border-radius-default-small);
color: var(--color-primary);
text-align: center;
background-color: var(--color-primary-tint);
small {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
}
.media-chip {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border: 0;
border-radius: var(--border-radius-default-small);
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background-color: var(--color-primary-tint);
&.self-destruct {
color: var(--color-orange);
background-color: var(--color-light-coral);
}
}
</style>