import type { mastodon } from "masto"; import { type Component, Index, Match, Switch, createMemo, createRenderEffect, createSignal, onCleanup, untrack, } from "solid-js"; import MediaViewer from "./MediaViewer"; import { render } from "solid-js/web"; import { createElementSize, useWindowSize, } from "@solid-primitives/resize-observer"; import { useStore } from "@nanostores/solid"; import { $settings } from "../settings/stores"; import { averageColorHex } from "../platform/blurhash"; import "./MediaAttachmentGrid.css"; import cardStyle from "../material/cards.module.css"; import { Preview } from "@suid/icons-material"; import { IconButton } from "@suid/material"; type ElementSize = { width: number; height: number }; function constraintedSize( { width: owidth, height: oheight }: Readonly<ElementSize>, // originalSize { width: mwidth, height: mheight }: Readonly<Partial<ElementSize>>, // modifier { width: maxWidth, height: maxHeight }: Readonly<ElementSize>, // maxSize ) { const ySize = owidth + (mwidth ?? 0); const yScale = ySize > maxWidth ? ySize / maxWidth : 1; const xSize = oheight + (mheight ?? 0); const xScale = xSize > maxHeight ? xSize / maxHeight : 1; const maxScale = Math.max(yScale, xScale); const scaledWidth = owidth / maxScale; const scaledHeight = oheight / maxScale; return { width: scaledWidth, height: scaledHeight, }; } function isolateCallback(event: Event) { if (event.target !== event.currentTarget) { event.stopPropagation(); } } const MediaAttachmentGrid: Component<{ attachments: mastodon.v1.MediaAttachment[]; sensitive?: boolean; }> = (props) => { const [rootRef, setRootRef] = createSignal<HTMLElement>(); const [viewerIndex, setViewerIndex] = createSignal<number>(); const viewerOpened = () => typeof viewerIndex() !== "undefined"; const settings = useStore($settings); const windowSize = useWindowSize(); const [reveal, setReveal] = createSignal([] as number[]); createRenderEffect(() => { const vidx = viewerIndex(); if (typeof vidx === "undefined") return; const container = document.createElement("div"); container.setAttribute("role", "presentation"); document.body.appendChild(container); const dispose = render(() => { onCleanup(() => { document.body.removeChild(container); }); return ( <MediaViewer show={viewerOpened()} index={viewerIndex() || 0} onIndexUpdated={setViewerIndex} media={props.attachments} onClose={() => setViewerIndex()} /> ); }, container); onCleanup(dispose); }); const openViewerFor = (index: number) => { setViewerIndex(index); }; const columnCount = () => { if (props.attachments.length === 1) { return 1; } else if (props.attachments.length % 2 === 0) { return 2; } else { return 3; } }; const rawElementSize = createElementSize(rootRef); const elementWidth = () => rawElementSize.width; const itemMaxSize = createMemo(() => { const ewidth = elementWidth(); const width = ewidth ? (ewidth - (columnCount() - 1) * 4) / columnCount() : 1; return { height: windowSize.height * 0.35, width, }; }); const itemStyle = (item: mastodon.v1.MediaAttachment) => { const { width, height } = constraintedSize( item.meta?.small || { width: 1, height: 1 }, { width: 2, height: 2 }, itemMaxSize(), ); const accentColor = item.meta?.colors?.accent ?? (item.blurhash ? averageColorHex(item.blurhash) : undefined); return Object.assign( { width: `${width}px`, height: `${height}px`, "contain-intrinsic-size": `${width}px ${height}px`, }, accentColor ? { "--media-color-accent": accentColor } : {}, ); }; const isReveal = (idx: number) => { return reveal().includes(idx); }; const addReveal = (idx: number) => { if (!untrack(() => isReveal(idx))) { setReveal((x) => [...x, idx]); } }; return ( <section ref={setRootRef} class={`MediaAttachmentGrid ${cardStyle.cardNoPad}`} classList={{ sensitive: props.sensitive, }} style={{ "column-count": columnCount() }} onClick={isolateCallback} > <Index each={props.attachments}> {(item, index) => { const itemType = () => item().type; return ( <div class="cell" role="presentation" style={itemStyle(item())} data-sort={index} data-media-type={item().type} > <Switch> <Match when={props.sensitive && !isReveal(index)}> <div class="sensitive-placeholder"> <IconButton color="inherit" size="large" onClick={[addReveal, index]} aria-label="Reveal this media" > <Preview /> </IconButton> </div> </Match> <Match when={itemType() === "image"}> <img src={item().previewUrl} width={item().meta?.small?.width} height={item().meta?.small?.height} alt={item().description || undefined} onClick={[openViewerFor, index]} loading="lazy" ></img> </Match> <Match when={itemType() === "video"}> <video src={item().url || undefined} autoplay={!props.sensitive && settings().autoPlayVideos} playsinline={settings().autoPlayVideos ? true : undefined} controls poster={item().previewUrl} width={item().meta?.small?.width} height={item().meta?.small?.height} /> </Match> <Match when={itemType() === "gifv"}> <video src={item().url || undefined} autoplay={!props.sensitive && settings().autoPlayGIFs} controls playsinline /* or safari on iOS will play in full-screen */ loop poster={item().previewUrl} width={item().meta?.small?.width} height={item().meta?.small?.height} /> </Match> <Match when={itemType() === "audio"}> <audio src={item().url || undefined} controls></audio> </Match> </Switch> </div> ); }} </Index> </section> ); }; export default MediaAttachmentGrid;