554 lines
14 KiB
Svelte
554 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { Dialog } from "bits-ui";
|
|
import { untrack } from "svelte";
|
|
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,
|
|
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";
|
|
import Icon from "#lib/components/ui/Icon.svelte";
|
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
|
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
|
import { t } from "#lib/i18n/locale.svelte.js";
|
|
import { poster } from "#lib/media/poster.js";
|
|
import { shareUi } from "#lib/stores/shares.svelte.js";
|
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
|
|
interface Props {
|
|
chatId: number;
|
|
index: number;
|
|
items: ViewerItem[];
|
|
ontrash?: (item: ViewerItem) => void;
|
|
open: boolean;
|
|
}
|
|
|
|
let {
|
|
open = $bindable(),
|
|
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);
|
|
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) ?? t("media.kind.other")
|
|
);
|
|
const caption = $derived(items[index]?.caption);
|
|
|
|
function revoke() {
|
|
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 && !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 (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;
|
|
}
|
|
if (current === token) {
|
|
result = next;
|
|
currentMediaId = mediaId;
|
|
fileName = name;
|
|
fileSize = size;
|
|
fileOnly = plainFile;
|
|
streamed = source !== null && !plainFile;
|
|
}
|
|
} catch {
|
|
if (current === token) {
|
|
toasts.error(t("media.loadFailed"));
|
|
}
|
|
} finally {
|
|
if (current === token) {
|
|
loading = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function queueFetch() {
|
|
if (messageId === null) {
|
|
return;
|
|
}
|
|
try {
|
|
await fetchMedia(chatId, messageId);
|
|
toasts.success(t("media.queued"));
|
|
} catch {
|
|
toasts.error(t("media.queueFailed"));
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
if (currentMediaId === null || saving) {
|
|
return;
|
|
}
|
|
saving = true;
|
|
try {
|
|
await downloadMedia(currentMediaId);
|
|
} catch {
|
|
toasts.error(t("ui.downloadFailed"));
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
function share() {
|
|
if (currentMediaId !== null) {
|
|
shareUi.share({ kind: "media", mediaId: currentMediaId }, title);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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>
|
|
{#if caption}
|
|
<span class="media-caption">{caption}</span>
|
|
{/if}
|
|
<div class="media-actions">
|
|
{#if canSave}
|
|
{#if ontrash}
|
|
<button
|
|
aria-label={t("storage.toTrash")}
|
|
class="media-action"
|
|
onclick={trash}
|
|
type="button"
|
|
>
|
|
<Icon name="delete" size="1.375rem" />
|
|
</button>
|
|
{/if}
|
|
<ContextMenu>
|
|
{#snippet children({
|
|
props,
|
|
})}
|
|
<button
|
|
{...props}
|
|
aria-label={t("ui.downloadFile")}
|
|
class="media-action"
|
|
onclick={save}
|
|
type="button"
|
|
>
|
|
<Icon name={saving ? "timer" : "download"} size="1.375rem" />
|
|
</button>
|
|
{/snippet}
|
|
{#snippet menu()}
|
|
<ContextMenuItem icon="download" onselect={save}>
|
|
{t("ui.downloadFile")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem icon="allow-share" onselect={share}>
|
|
{t("ui.shareLink")}
|
|
</ContextMenuItem>
|
|
{/snippet}
|
|
</ContextMenu>
|
|
<button
|
|
aria-label={t("ui.shareLink")}
|
|
class="media-action"
|
|
onclick={share}
|
|
type="button"
|
|
>
|
|
<Icon name="allow-share" size="1.375rem" />
|
|
</button>
|
|
{/if}
|
|
<Dialog.Close aria-label={t("common.close")} class="media-close">
|
|
<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 alt={title} class="media-image" src={result.url}>
|
|
{: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"
|
|
controls
|
|
playsinline
|
|
preload={streamed ? "metadata" : "auto"}
|
|
src={result.url}
|
|
use:poster
|
|
></video>
|
|
{:else if result?.state === "ready" && isAudio}
|
|
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
|
|
<audio controls src={result.url}></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}
|
|
{#if currentMediaId !== null}
|
|
<button class="media-download" onclick={save} type="button">
|
|
<Icon name="download" />
|
|
{t("ui.downloadFile")}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{:else if result?.state === "not-downloaded"}
|
|
<div class="media-message">
|
|
<p>{t("media.notDownloaded")}</p>
|
|
<Button fluid onclick={queueFetch} variant="primary">
|
|
{t("media.downloadToArchive")}
|
|
</Button>
|
|
</div>
|
|
{:else if result?.state === "missing"}
|
|
<p class="media-message">{t("media.missing")}</p>
|
|
{/if}
|
|
</div>
|
|
{#if hasNav}
|
|
<button
|
|
aria-label={t("ui.previous")}
|
|
class="media-nav prev"
|
|
disabled={index === 0}
|
|
onclick={() => step(-1)}
|
|
type="button"
|
|
>
|
|
<Icon name="previous" size="1.75rem" />
|
|
</button>
|
|
<button
|
|
aria-label={t("ui.next")}
|
|
class="media-nav next"
|
|
disabled={index === items.length - 1}
|
|
onclick={() => step(1)}
|
|
type="button"
|
|
>
|
|
<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-caption {
|
|
position: absolute;
|
|
top: 2.75rem;
|
|
left: 1.25rem;
|
|
|
|
overflow: hidden;
|
|
max-width: min(24rem, calc(100% - 14rem));
|
|
|
|
font-size: 0.8125rem;
|
|
color: rgba(255, 255, 255, 0.6);
|
|
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>
|