feat(frontend,backend): storage panel with weight rows and trash, heavy media streams by url in the viewer

This commit is contained in:
hh committed 2026-10-14 03:09:37 +02:00
1 parent f2c245707a
commit 55634984fd
19 files changed
+1822 -24

No files matched your search

+58 -10
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { type MediaResult, requestMedia } from "#lib/api/client.js";
import { type MediaResult, mediaUrl, requestMedia } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js";
import { fetchMedia, getMessageMedia } from "#lib/api/endpoints.js";
import { isPreviewable, type ViewerItem } from "#lib/api/media.js";
import {
isPreviewable,
isStreamable,
type ViewerItem,
} from "#lib/api/media.js";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
@@ -19,6 +23,7 @@
chatId: number;
index: number;
items: ViewerItem[];
ontrash?: (item: ViewerItem) => void;
open: boolean;
}
@@ -27,14 +32,18 @@
index = $bindable(),
chatId,
items,
ontrash,
}: Props = $props();
const STREAM_BYTES = 32 * 1024 * 1024;
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 streamed = $state(false);
let result = $state<MediaResult | null>(null);
let loading = $state(false);
let saving = $state(false);
@@ -55,33 +64,51 @@
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
function revoke() {
if (result?.state === "ready" && result.url) {
if (result?.state === "ready" && result.url && !streamed) {
URL.revokeObjectURL(result.url);
}
}
function streamSource(
item: ViewerItem,
mediaId: number | null,
size: number | null
): string | null {
if (item.src) {
return item.src;
}
const heavy = size !== null && size > STREAM_BYTES;
return mediaId !== null && heavy && isStreamable(kind, item.mime)
? mediaUrl(mediaId)
: null;
}
async function load(item: ViewerItem) {
revoke();
loading = true;
result = null;
currentMediaId = null;
fileOnly = false;
streamed = false;
({ kind, messageId, fileName, fileSize } = item);
token += 1;
const current = token;
try {
let { mediaId, downloaded, mime: mimeType } = item;
let { fileName: name, fileSize: size } = item;
if (mediaId === null) {
if (mediaId === null && !item.src) {
const meta = await getMessageMedia(chatId, item.messageId);
({ id: mediaId, downloaded, kind, mime: mimeType } = meta);
({ file_name: name, file_size: size } = meta);
}
const plainFile = downloaded && !isPreviewable(kind, mimeType);
const source = downloaded ? streamSource(item, mediaId, size) : null;
let next: MediaResult;
if (plainFile) {
next = { mime: mimeType, state: "ready", url: "" };
} else if (downloaded) {
} else if (source !== null) {
next = { mime: mimeType, state: "ready", url: source };
} else if (downloaded && mediaId !== null) {
next = await requestMedia(mediaId);
} else {
next = { state: "not-downloaded" } as MediaResult;
@@ -92,6 +119,7 @@
fileName = name;
fileSize = size;
fileOnly = plainFile;
streamed = source !== null && !plainFile;
}
} catch {
if (current === token) {
@@ -136,6 +164,14 @@
}
}
function trash() {
const item = items[index];
if (item && ontrash) {
open = false;
ontrash(item);
}
}
function step(delta: number) {
const next = index + delta;
if (next >= 0 && next < items.length) {
@@ -214,6 +250,16 @@
>
<Icon name="allow-share" size="1.375rem" />
</button>
{#if ontrash}
<button
aria-label="В корзину"
class="media-action"
onclick={trash}
type="button"
>
<Icon name="delete" size="1.375rem" />
</button>
{/if}
{/if}
<Dialog.Close aria-label="Закрыть" class="media-close">
<Icon name="close" size="1.5rem" />
@@ -234,7 +280,7 @@
class="media-video"
controls
playsinline
preload="auto"
preload={streamed ? "metadata" : "auto"}
src={result.url}
use:poster
></video>
@@ -248,10 +294,12 @@
{#if fileSize}
<p class="file-size">{formatBytes(fileSize)}</p>
{/if}
<button class="media-download" onclick={save} type="button">
<Icon name="download" />
Скачать файл
</button>
{#if currentMediaId !== null}
<button class="media-download" onclick={save} type="button">
<Icon name="download" />
Скачать файл
</button>
{/if}
</div>
{:else if result?.state === "not-downloaded"}
<div class="media-message">