@ai-matrx/media
v0.6.6
Published
The AI Matrx media components kit: durable-ref-only renderers for images, video, audio and file thumbnails that just work with the Matrx file system — headless core hooks plus DOM bindings, wired to an injected MediaClient. A signed URL is a handoff, neve
Maintainers
Readme
@ai-matrx/media
Media components that just work with a durable file system. Renderers for
images, video, audio and file thumbnails that take a durable reference — a
file_id or a permanent public URL — and own the whole URL lifecycle:
resolution, auth transport, load-error recovery, typed unavailable states,
upload progress, and share/download/copy/open actions.
The package ships zero transport. Everything data-shaped arrives through
one injected port, the MediaClient. That is the point: every media breakage
we ever shipped came from code that hand-built a URL or an <img>; here the
correct path is the only path the types allow.
@ai-matrx/media types only — MediaRef, DurableSrc, MediaClient, host ports
@ai-matrx/media/core headless React hooks (platform-free state machines)
@ai-matrx/media/react DOM components (Tailwind classes, inlined SVG icons)
@ai-matrx/media/share the complete share popover body (optional peer: @ai-matrx/associations)The rules the types enforce
- The package never fetches a media URL itself. Bytes exist only via
MediaClient.getBlob. - Every component takes a ref, never a
src. Even a caller-supplied URL string is classified byresolve(), never trusted. - A signed URL is a handoff, never an identity. The only string a media
element binds is the branded
DurableSrc, which only aMediaClientimplementation can mint. An expiring URL cannot pass the types without an unsafe cast. - Share affordances fail closed.
shareableUrlreturnsnullrather than ever emitting a signed URL; unwired actions render disabled. - One retry policy. Load errors route through
MediaClient.recoverLoadError(session refresh → same-URL retry → terminal). The package has no retry logic of its own.
Quick start
import { MediaProvider } from "@ai-matrx/media/core";
import { InlineMediaRef, MediaThumbnail, MediaLightbox } from "@ai-matrx/media/react";
// `client` implements the MediaClient interface from "@ai-matrx/media".
// Matrx apps wire the canonical implementation from @ai-matrx/data/files;
// any structural implementation works.
<MediaProvider client={client} ports={{ ImageComponent, actions, playbackSession }}>
<InlineMediaRef ref={{ file_id }} size="md" fit="cover" />
<MediaThumbnail mediaRef={{ file_id }} fileName="report.pdf" />
</MediaProvider>InlineMediaRef accepts a MediaRef ({ file_id, url?, mime_type? }), a
bare fileId string, or an external public URL string — and renders a
correctly-sized <img> / <video> / <audio> with fallback and informative
error states. ref is a content prop (React 19); use mediaElementRef for
DOM access.
Headless hooks (/core)
| Hook | What it owns |
|---|---|
| useMediaResolution(ref) | ref → MediaResolution as state (empty / ready / unavailable with a typed reason) |
| useMediaLoadRecovery(src) | the retry-key/error machine over the client's ONE recovery policy |
| useMediaBlob(ref) | object-URL bytes with lifecycle + release |
| useMediaUpload() | single + batch upload, progress, and a per-file tray (entries) |
| useThumbnailSource(ref, opts) | the 4-tier thumbnail decision (rendered thumb → variant → live source → icon) |
| useMediaActions(ref) | share/download/copy/open availability + execution over the injected actions port |
| getFileKindDetails(name, mime) | filename/MIME → category, icon name, color |
Components (/react)
InlineMediaRef · MediaThumbnail · FileIcon · FileUploadDropzone (+
UploadProgressList, useMediaDropzone) · MediaActionToolbar ·
MediaLightbox — plus the package's own inlined SVG icon set (no icon
library dependency).
Actions & sharing
Hosts inject a MediaActionsPort (share, download, copy, open
handlers and/or a rich SharePopover component). The toolbar and lightbox
render whatever is wired and disable the rest — sharing integration plugs in
without touching this package.
<MediaProvider client={client} ports={{ actions: { download, share, SharePopover } }}>
<MediaActionToolbar mediaRef={{ file_id }} fileName="photo.png" />
</MediaProvider>The complete share body (/share)
/share ships the popover BODY the slot was reserved for — the canonical
Matrx share experience:
- Copy public link — one click asks
MediaClient.shareableUrl(ref)for the truly-permanent URL (the host's implementation reuses or mints a no-expiry share link, or returns the permanent CDN URL for public files) and copies it.nullfails closed — never a signed URL. - External refs get the simplified surface: the safe external URL or a disabled row.
- Manage all links / who-can-see-this are host slots
(
manageLinkscallback,AccessSummarycomponent) — link inventory and reachability live with the host's iam surfaces. - Linked to / Attach to… — the association affordances, riding
@ai-matrx/associationsthrough its public hooks. They light up when anAssociationsProvideris mounted above and degrade to absent when not.
import { createMediaSharePopover } from "@ai-matrx/media/share";
const SharePopover = createMediaSharePopover({
manageLinks: (ctx) => openShareLinkDialog(ctx), // optional
AccessSummary, // optional host panel
notify: { success: toast.success, error: toast.error }, // optional
});
<MediaProvider client={client} ports={{ actions: { SharePopover, download, copy } }}>
{/* every toolbar/lightbox now opens the full share experience */}
</MediaProvider>Only /share imports @ai-matrx/associations — a REAL dependency, installed
automatically (it brings @ai-matrx/design-system with it). Nothing extra to
resolve; association affordances still degrade to absent at runtime when no
AssociationsProvider wraps the tree. The other entries never touch it.
Diagnostics — a terminal failure is never silent
Every dead render (a refused resolve(), failed private-pixel bytes, a load
error the ONE recovery retry couldn't fix, a failed session mint) emits
exactly one strictly-typed MediaFailureInfo — phase, durable mediaRef
identity (never a signed URL), status?, retryOutcome?, terminal,
message — latched per media identity so re-renders never spam. Bind the
sink to your error system:
The retry allowance advances only after React commits the retry-key remount;
duplicate browser error events from the original element while the session
refresh is in flight are ignored and never consume the real retry.
<MediaProvider
client={client}
ports={{ diagnostics: { capture: (info) => myErrorCapture(info) } }}
>Without a bound sink the package console.errors the same payload — loud by
default, never silent. If your MediaClient.recoverLoadError returns a
MediaRecoveryReport carrying the session mint's
secret_source: "ephemeral", the package immediately captures an app-wide
warning that private renders will break across server restarts/instances.
File viewers (/viewers)
Full-pane rendering of ONE file — a preview pane, a file tab, a code editor's
binary-file fallback. MediaFileViewer dispatches on the file kind and ships
working bodies for text, code, SVG, HTML and unknown files.
import { MediaFileViewer } from "@ai-matrx/media/viewers";
<MediaFileViewer
source={{ kind: "ref", ref: { file_id } }}
fileName={file.name}
fileSize={file.size}
generic={{ onDownload }}
/>;Kinds whose rendering IS a heavy browser-only engine — PDF, Markdown, spreadsheets, Office documents — are yours to register, so a consumer that only shows text files never carries a PDF engine:
import { registerMediaViewer, registerMediaCodeHighlighter } from "@ai-matrx/media/viewers";
registerMediaViewer("pdf", MyPdfViewer); // gets MediaViewerProps
registerMediaCodeHighlighter(MyPrismHighlighter); // optional: colour for codeUntil you register one, that kind renders an announcing default: it names the missing engine and the remedy and still offers download / view-as-text. It is never a blank pane. (Code is different: with no highlighter registered it renders complete — monospace, line-numbered, copyable — just without colour, so it announces nothing.)
Registering a kind the package already renders replaces the built-in, which is how you plug in a domain-specific viewer of your own.
The blob source lane ({ kind: "blob", blob, url }) renders bytes you
already have without touching the MediaClient at all.
Next.js (/next)
The ImageComponent port, implemented over next/image so you never write
that wrapper again. next is an optional peer — nothing installs it for you,
and no other entry imports it.
import { NextMediaImage } from "@ai-matrx/media/next";
const ports: MediaHostPorts = { ImageComponent: NextMediaImage };
// or: createNextMediaImageComponent({ unoptimized: false, quality: 90 })It defaults to unoptimized, which is correct for Matrx media: durable file
URLs are session-cookie authenticated and Next's optimizer fetches them from
the server, where there is no user session.
Styling
/react uses Tailwind utility classes with semantic tokens (bg-muted,
text-destructive, …). Tailwind v4 hosts register the package as a source:
@source "../node_modules/@ai-matrx/media";SSR / RSC
The root entry is server-safe (types + one pure function). /core, /react,
/share, /viewers and /next are client modules ("use client" is stamped
per entry). Nothing touches window at import time.
License
MIT. Inlined icon paths copied from Lucide (ISC).
