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

492 lines
12 KiB
Svelte

<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { type MediaResult, requestMedia } from "$lib/api/client";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia, getMessageMedia } from "$lib/api/endpoints";
import { isPreviewable, type ViewerItem } from "$lib/api/media";
import Button from "$lib/components/ui/Button.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";
interface Props {
chatId: number;
index: number;
items: ViewerItem[];
open: boolean;
}
let {
open = $bindable(),
index = $bindable(),
chatId,
items,
}: Props = $props();
let kind = $state("");
let messageId = $state<number | null>(null);
let currentMediaId = $state<number | null>(null);
let fileName = $state<string | null>(null);
let fileSize = $state<number | null>(null);
let fileOnly = $state(false);
let result = $state<MediaResult | null>(null);
let loading = $state(false);
let saving = $state(false);
let token = 0;
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
const isImage = $derived(mime.startsWith("image/") || kind === "photo");
const isVideo = $derived(
mime.startsWith("video/") || kind === "video" || kind === "video_note"
);
const isAudio = $derived(
mime.startsWith("audio/") || kind === "voice" || kind === "audio"
);
const hasNav = $derived(items.length > 1);
const canSave = $derived(
currentMediaId !== null && (fileOnly || result?.state === "ready")
);
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
function revoke() {
if (result?.state === "ready" && result.url) {
URL.revokeObjectURL(result.url);
}
}
async function load(item: ViewerItem) {
revoke();
loading = true;
result = null;
kind = item.kind;
messageId = item.messageId;
currentMediaId = null;
fileName = item.fileName;
fileSize = item.fileSize;
fileOnly = false;
const current = ++token;
try {
let mediaId = item.mediaId;
let downloaded = item.downloaded;
let name = item.fileName;
let size = item.fileSize;
let mimeType = item.mime;
if (mediaId === null) {
const meta = await getMessageMedia(chatId, item.messageId);
mediaId = meta.id;
downloaded = meta.downloaded;
kind = meta.kind;
name = meta.file_name;
size = meta.file_size;
mimeType = meta.mime;
}
const plainFile = downloaded && !isPreviewable(kind, mimeType);
let next: MediaResult;
if (plainFile) {
next = { state: "ready", url: "", mime: mimeType };
} else if (downloaded) {
next = await requestMedia(mediaId);
} else {
next = { state: "not-downloaded" } as MediaResult;
}
if (current === token) {
result = next;
currentMediaId = mediaId;
fileName = name;
fileSize = size;
fileOnly = plainFile;
}
} catch {
if (current === token) {
toasts.error("Failed to load media");
}
} finally {
if (current === token) {
loading = false;
}
}
}
async function queueFetch() {
if (messageId === null) {
return;
}
try {
await fetchMedia(chatId, messageId);
toasts.success("Скачивание поставлено в очередь");
} catch {
toasts.error("Не удалось поставить в очередь");
}
}
async function save() {
if (currentMediaId === null || saving) {
return;
}
saving = true;
try {
await downloadMedia(currentMediaId);
} catch {
toasts.error("Не удалось скачать файл");
} finally {
saving = false;
}
}
function share() {
if (currentMediaId !== null) {
shareUi.share({ kind: "media", mediaId: currentMediaId }, title);
}
}
function step(delta: number) {
const next = index + delta;
if (next >= 0 && next < items.length) {
index = next;
}
}
function onkeydown(event: KeyboardEvent) {
if (!(open && hasNav)) {
return;
}
if (event.key === "ArrowLeft") {
step(-1);
} else if (event.key === "ArrowRight") {
step(1);
}
}
$effect(() => {
const item = items[index];
const isOpen = open;
untrack(() => {
if (isOpen && item) {
load(item);
}
});
});
$effect(() => {
if (!open) {
untrack(() => {
revoke();
result = null;
});
}
});
</script>
<svelte:window {onkeydown} />
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="media-overlay" />
<Dialog.Content class="media-content">
<Dialog.Title class="media-title">{title}</Dialog.Title>
<div class="media-actions">
{#if canSave}
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="media-action"
aria-label="Скачать файл"
onclick={save}
>
<Icon name={saving ? "timer" : "download"} size="1.375rem" />
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/snippet}
</ContextMenu>
<button
type="button"
class="media-action"
aria-label="Доступ по ссылке"
onclick={share}
>
<Icon name="allow-share" size="1.375rem" />
</button>
{/if}
<Dialog.Close class="media-close" aria-label="Закрыть">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
</div>
{#if hasNav}
<span class="media-counter">{index + 1} / {items.length}</span>
{/if}
<div class="media-body">
{#if loading}
<Spinner color="white" />
{:else if result?.state === "ready" && isImage}
<img class="media-image" src={result.url} alt={kind}>
{:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<video
class="media-video"
src={result.url}
controls
playsinline
preload="auto"
use:poster
></video>
{:else if result?.state === "ready" && isAudio}
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<audio src={result.url} controls></audio>
{:else if result?.state === "ready"}
<div class="media-file">
<span class="file-glyph"><Icon name="document" size="2rem" /></span>
<p class="file-name">{title}</p>
{#if fileSize}
<p class="file-size">{formatBytes(fileSize)}</p>
{/if}
<button class="media-download" type="button" onclick={save}>
<Icon name="download" />
Скачать файл
</button>
</div>
{:else if result?.state === "not-downloaded"}
<div class="media-message">
<p>Файл ещё не скачан в архив.</p>
<Button variant="primary" fluid onclick={queueFetch}>
Скачать в архив
</Button>
</div>
{:else if result?.state === "missing"}
<p class="media-message">Файл не найден.</p>
{/if}
</div>
{#if hasNav}
<button
class="media-nav prev"
type="button"
aria-label="Previous"
disabled={index === 0}
onclick={() => step(-1)}
>
<Icon name="previous" size="1.75rem" />
</button>
<button
class="media-nav next"
type="button"
aria-label="Next"
disabled={index === items.length - 1}
onclick={() => step(1)}
>
<Icon name="next" size="1.75rem" />
</button>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
:global(.media-overlay) {
position: fixed;
inset: 0;
z-index: var(--z-media-viewer);
background-color: rgba(0, 0, 0, 0.9);
}
:global(.media-content) {
position: fixed;
inset: 0;
z-index: var(--z-media-viewer);
display: flex;
flex-direction: column;
outline: none;
}
:global(.media-title) {
position: absolute;
top: 1.25rem;
left: 1.25rem;
overflow: hidden;
max-width: min(24rem, calc(100% - 14rem));
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
text-overflow: ellipsis;
white-space: nowrap;
}
.media-counter {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.9375rem;
color: var(--color-white);
}
:global(.media-close) {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border: 0;
border-radius: 50%;
color: var(--color-white);
background-color: rgba(255, 255, 255, 0.1);
}
.media-body {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
}
.media-image,
.media-video {
max-width: 90vw;
max-height: 85vh;
border-radius: var(--border-radius-default);
}
.media-message {
max-width: 22rem;
color: var(--color-white);
text-align: center;
}
.media-file {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
max-width: min(24rem, 90vw);
color: var(--color-white);
text-align: center;
}
.file-glyph {
display: flex;
align-items: center;
justify-content: center;
width: 4.5rem;
height: 4.5rem;
margin-bottom: 0.5rem;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.12);
}
.file-name {
overflow-wrap: anywhere;
margin: 0;
font-size: 1rem;
}
.file-size {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.6);
}
.media-download {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: 0;
border-radius: 1.5rem;
font-family: inherit;
font-size: 0.9375rem;
color: var(--color-white);
cursor: pointer;
background-color: rgba(255, 255, 255, 0.12);
}
.media-actions {
position: absolute;
top: 0.75rem;
right: 1rem;
display: flex;
align-items: center;
gap: 0.375rem;
z-index: 1;
}
.media-action {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border: 0;
border-radius: 50%;
color: var(--color-white);
background-color: rgba(255, 255, 255, 0.1);
}
.media-nav {
cursor: pointer;
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
color: var(--color-white);
background-color: rgba(255, 255, 255, 0.1);
&:disabled {
cursor: default;
opacity: 0.3;
}
&.prev {
left: 1.25rem;
}
&.next {
right: 1.25rem;
}
}
</style>