Storage & video with React
Audience: React developers who let their users upload files, browse them and watch video that is stored and delivered by plycdn. The package gives you hooks and ready-made components: a resumable upload area, a file browser, links that renew themselves before they expire, an accessible video player, and a small client for your backend's plycdn routes. It needs React 18 or later.
The browser never holds your API key and never chooses an organization. Every call goes to your
backend, which signs the user in, asks your IPlycdnStorageAuthorizer whether they may do this (the
default refuses everything) and adds the key. File bytes go from the browser straight to the bucket's region;
each part is authorised only by a short-lived ticket in its URL: no cookies, no headers. Your backend setup is
in Storage & video with ASP.NET Core; do that first, so the routes the hooks call
exist.
1Install
Copy plycdn-react-1.4.0.tgz into your application's vendor directory and install it:
npm install ./vendor/plycdn-react-1.4.0.tgz
That is the whole install. The package installs what it needs automatically from your npm registry or mirror, so make sure yours can serve it. The player loads its streaming code only in browsers that need it; browsers that play the stream natively never download it. Commit the package file and your lockfile, or host the package in your private npm registry so your CI can install it.
2Choose what you import
Import by purpose, from the subpath you need (exports in the package; the root holds only shared types):
| Subpath | What it holds |
|---|---|
@plycdn/react/storage |
PlycdnProvider, PlycdnUpload, PlycdnFileBrowser, PlycdnUploader, useUpload, useObjects, useObject, useSignedUrl, useUnfinishedUploads, createPlycdnClient |
@plycdn/react/video |
PlycdnProvider, PlycdnPlayer, usePlayback, useVideo, PlycdnPlayerCore, createPlycdnClient |
Both subpaths export the same PlycdnProvider: one provider serves a page that uses both. Wrap the part of
your application that uses the components:
import { PlycdnProvider } from '@plycdn/react/storage'; // or '@plycdn/react/video'
export function Root() {
return (
<PlycdnProvider config={{ baseUrl: '/api/plycdn/v1' }}>
<App />
</PlycdnProvider>
);
}
baseUrldefaults to/api/plycdn/v1, where the .NET package serves its routes.- Set
credentials: 'include'when your backend is on another origin and uses cookie sign-in. - Pass
fetchto add your own sign-in header, retries or telemetry. - The provider also takes
client(your own client, for tests or to wrap every call),uploader,links,rememberFileNamesandnonce.
If your site sends a Content-Security-Policy, allow the upload hosts with connect-src https://*.upload.plycdn.com. The components add one <style> element: pass nonce to the provider when your
style-src has no 'unsafe-inline', or pass unstyled to each component (it is a prop of the components, not of the provider) and include PLYCDN_CSS in your own stylesheet.
3Upload
import { PlycdnUpload } from '@plycdn/react/storage';
<PlycdnUpload bucketId={bucketId} prefix="week-1/" onStored={() => browser.current?.refresh()} onFailed={log} />
- Drag files onto the area or choose them. Each file's key is
prefix + file.name, or whateverkeyFor(file, prefix)returns. - Each file shows progress, time left, Pause / Resume, Cancel and Retry. Files beyond
parallelFiles(default 2) wait their turn;concurrency(default 3) is the number of parts of one file in flight at once. - Resumable. A dropped connection is retried, and sending waits while the browser is offline. After a reload or a closed tab, the component lists the bucket's unfinished uploads; choosing the same file again continues from the last part that arrived. Sessions stay open for 7 days; after that they are no longer offered, and the browser forgets them.
- Never uploaded twice. A lost or timed-out completion is asked again. A file whose upload already completed returns its stored object instead of being sent (and billed) a second time.
- Keyboard and screen readers. Pause and Resume are one button (
aria-pressed); after Retry, Cancel or Remove, focus moves to the row's next action; progress is a labelledprogressbar; errors arerole="alert"; a polite live region announces what happened. - Retry appears only where it can help. A refusal such as
object_existsorfile_type_not_allowedoffers Remove instead, which also ends the session. - Errors are sentences, not codes.
messages={{ file_type_not_allowed: 'Only videos and PDFs.' }}rewords (or translates) any of them.PLYCDN_MESSAGESlists the defaults by code;onFailedreceives aPlycdnError. - Every word is in
labels(defaults:PLYCDN_UPLOAD_LABELS). Other props:accept,multiple,disabledandwarnOnLeave(asks before the page closes mid-upload). Unmounting pauses uploads rather than ending them, so they stay resumable.
For each unfinished upload the browser keeps only its session id, its expiry and (to list it by name) its key.
On shared computers set rememberFileNames={false} on the component, or on the provider for the whole app. On
sign-out, clear the list:
const { forgetAll } = useUnfinishedUploads(bucketId);
forgetAll(); // every bucket; forgetAll(bucketId) for one. The sessions expire on their own.
To upload into a video library, give the library's id as bucketId and upload under sources/ (see the
Video guide); completing the upload creates the video.
4File browser
import { PlycdnFileBrowser, type PlycdnFileBrowserHandle } from '@plycdn/react/storage';
const browser = useRef<PlycdnFileBrowserHandle>(null);
<PlycdnFileBrowser ref={browser} bucketId={bucketId} onOpen={setOpened} />
- Folders come from
/in keys, with breadcrumbs. Show more pages through the listing (pageSize, default 100). prefixwithonPrefixChangecontrols the folder;defaultPrefixlets the component track it.- Unavailable and damaged files are marked, and a refusal is explained with Try again.
ref.current.refresh()reloads the folder, for example after an upload.onOpenreceives the stored object. Without it, names are shown as text.
A public bucket's object has a url. For a private one, ask your backend for a signed link. The link renews
itself before it expires:
import { useSignedUrl, type PlycdnObject } from '@plycdn/react/storage';
function Clip({ object }: { object: PlycdnObject }) {
const { url, error } = useSignedUrl(object.bucketId, object.key);
if (error) return <p role="alert">{error.message}</p>;
return url ? <video src={url} controls /> : null;
}
5Play video
import { PlycdnPlayer } from '@plycdn/react/video';
<PlycdnPlayer libraryId={libraryId} videoId={videoId} onStatus={status => console.log(status)} />
- Links come from your backend. The player asks your backend (
POST {baseUrl}/signwithbucketId= the library andvideoId) for a playback link; your authorizer decides who may watch (PlycdnOperation.WatchVideo, andGetVideofor the title and status). The video itself streams from*.plycdn.net. Links are renewed at 80 % of their lifetime, and at once if the delivery network refuses one; playback carries on without a reload.ttlSecondssets how long each link lives (your backend's default otherwise). - Resumes where this browser last stopped (
resume={false}turns it off; the position is kept inlocalStorageunderplycdn-position:<videoId>, and nothing breaks where storage is blocked). - While a video is still being prepared (
playable: the lowest quality is ready) the player checks every 15 seconds and, once every quality is ready, reloads at the same position. - Keyboard, while the player has focus: Space or K play/pause, Left/Right 5 seconds, J/L 10 seconds,
Up/Down volume, M mute, C next captions track, F full screen, Home/End, 0 to 9 jump to 0 to 90 %,
<and>slower and faster. Shortcuts are ignored with Ctrl, Cmd or Alt, and while a text field has focus. Every control is a labelled button reachable with Tab; the seek bar is a slider read in words; changes are announced to screen readers; motion is reduced when the system asks. - Quality (Auto first), speed and captions menus; captions are offered when the video has them. Download appears when the library offers downloads. Scrubbing previews show on hover and focus.
onStatusreceives{ status: 'loading' | 'ready' | 'error', error, message }. Error codes:video_not_found,video_not_ready,playback_unsupported(a browser that cannot stream),playback_link_failed(three renewals failed; the player offers Try again), and your backend's own refusals such asforbidden.messagesrewords them like the other components.- Other props:
className,unstyledandhasMse(override the detection of Media Source Extensions).
If your site sends a Content-Security-Policy, allow the delivery hosts:
connect-src https://*.plycdn.net; media-src https://*.plycdn.net blob:; img-src https://*.plycdn.net; worker-src blob:.
Your own player
import { usePlayback, useVideo } from '@plycdn/react/video';
function Lecture({ libraryId, videoId }: { libraryId: string; videoId: string }) {
const { data: video } = useVideo(videoId); // title, status; polls while it is being prepared
const { src, poster, error, refresh } = usePlayback(libraryId, videoId, { ttlSeconds: 14400 });
if (error) return <p role="alert">{error.message}</p>;
return <MyPlayer title={video?.title} src={src} poster={poster} onForbidden={refresh} />;
}
usePlayback shares one link per (library, video, ttl) with every component under the provider and renews it
like useSignedUrl: at about 80 % of its life, on wake-up, and when you call refresh() (do that when your
player gets a 403 from the delivery network). src is the stream (an adaptive-streaming playlist whose token your
player must carry on every request, as PlycdnPlayerCore does); nativeSrc is the same stream for native
playback, or undefined. PlycdnPlayerCore is the framework-free engine <PlycdnPlayer> uses (link renewal,
the token on every request, resume and the keyboard map), shared with the Angular package; give it a <video>
element and a getLink function. The Video guide shows a complete example.
6Hooks
| Hook | Returns |
|---|---|
useObjects(bucketId, { prefix, limit }) |
{ items, loading, error, hasMore, loadMore, reload } |
useObject(objectId) |
{ data, loading, error, reload } |
useUpload() |
{ start(file, { bucketId, key?, contentType?, sha256?, concurrency?, rememberFileNames? }), pause, resume, abort, retry, reset, progress: { sent, total, percent }, state, error, object } |
useSignedUrl(bucketId, key, { ttlSeconds?, hostname? }) |
{ url, expiresAt, loading, error, refresh } |
useUnfinishedUploads(bucketId) |
{ items, forget(item), forgetAll(bucketId?), reload } |
usePlayback(libraryId, videoId, { ttlSeconds? }) |
{ src, nativeSrc, poster, thumbnails, download, expiresAt, loading, error, refresh } |
useVideo(videoId, { pollWhileProcessing?, pollIntervalMs? }) |
{ data, loading, error }; polls every 15 s while the video is uploading, queued, encoding or playable |
useUpload.stateisidle, then one ofstarting,uploading,paused,retrying,offline,completing,stored,failedoraborted;retryingandofflinerecover by themselves.startresolves with the stored object, or withundefinedon failure (readerror) or when anotherstartfrom the same hook replaced it. Progress re-renders about ten times a second, however fast the bytes go.useSignedUrl. It asks your backend'ssignroute for a link. Every component under the same provider showing the same file (bucket, key, ttl, hostname) shares one link and one renewal, which stops when the last of them unmounts.hostnamesigns on one of the bucket's active custom domains; left out it is the bucket's default hostname, and a hostname that is not an active domain of the bucket fails withdomain_not_active. It renews at about 80 % of the link's life, spread by a few percent so links issued together do not all renew at once. The life is the one the server issued (itsDateheader againstexpiresAt), so a wrong clock in the browser changes nothing; if your backend is on another origin, addDateto itsAccess-Control-Expose-Headers, or the life is estimated and kept between 30 seconds and 15 minutes. A link in its last 10 % of life is never returned:urlis undefined andloadingtrue until the new one arrives. A failed renewal keeps the current link, without an error, while it still works;erroris set only when there is no working link to show. A public bucket's link (expiresAt: null) is not renewed.- All hooks ignore an answer that arrives after a newer request, so a slow response never overwrites a fast one, and never show data for a previous id, key or folder.
7Errors
Every failure is a PlycdnError: code is the server's code, or network_unreachable when nothing
answered; status is the HTTP status, or 0; retryable says whether trying again can help; message is a
sentence you can show. invalid_response means a 2xx answer that is not JSON, almost always a baseUrl that
reaches a web page (an HTML fallback) instead of your backend's plycdn routes; it is shown as an error, never
as an empty folder. describeError(error, messages) gives the sentence with your own wording first, and
canRetryUpload(error) says whether a retry can get an upload further.
8Client and uploader
createPlycdnClient({ baseUrl, fetch?, credentials? }) has these methods, all returning promises and each
taking a last { signal } argument to cancel it: listObjects, getObject, startUpload, getUpload,
renewUpload, completeUpload, abortUpload, sign and getVideo. sign(bucketId, key, ttlOrOptions?, { signal }?) takes either the lifetime in seconds or { ttlSeconds?, hostname? } as its third argument and
resolves with { url, expiresAt, lifetimeSeconds? }; sign({ bucketId: libraryId, videoId, ttlSeconds? })
returns a video's PlaybackLink (url, nativeUrl, expiresAt, posterUrl, thumbnailsUrl,
downloadUrl). An organization can hold at most 1,000 open uploads at once; startUpload beyond that fails
with plan_limit_reached until one completes, is aborted or expires, so abort uploads you no longer need.
PlycdnUploader is the framework-free uploader, shared with the Angular package so both upload identically:
import { PlycdnUploader, createPlycdnClient } from '@plycdn/react/storage';
const client = createPlycdnClient({ baseUrl: '/api/plycdn/v1' });
const uploader = new PlycdnUploader(client); // (api, transport?, store?, { persistNames?: boolean })
const upload = uploader.upload(file, { bucketId, key: 'week-1/intro.mp4', onProgress: (sent, total) => {}, onStateChange: state => {} });
upload.pause(); upload.resume();
const object = await upload.done; // or: await upload.abort();
uploader.pendingUploads(bucketId); await uploader.forget(pending); uploader.forgetAll();
Pass your own uploader to PlycdnProvider to change how parts are sent or where sessions are remembered
(localStorage by default), or your own links (new PlycdnLinks(client, options)) to change the clocks the
signed-link renewal uses.
Tags on links: a sign request can carry an optional tags (names and values as in the ASP.NET Core guide). Your backend decides whether they are used: by default tags a browser sends are ignored, so set them on your server.
9Theme
Everything is a CSS custom property on the component (class plycdn) or any ancestor:
- colours:
--plycdn-accent,--plycdn-on-accent,--plycdn-text,--plycdn-muted,--plycdn-surface,--plycdn-border,--plycdn-error,--plycdn-success,--plycdn-focus,--plycdn-linkand--plycdn-background; - shape and type:
--plycdn-radius,--plycdn-font-family,--plycdn-font-size,--plycdn-line-height,--plycdn-spacing,--plycdn-control-heightand--plycdn-button-padding; - progress:
--plycdn-progress-heightand--plycdn-progress-track; - drop area:
--plycdn-drop-backgroundand--plycdn-drop-active-background; - video player:
--plycdn-player-background,--plycdn-player-bar,--plycdn-player-textand--plycdn-player-max-height(default 80vh).
Fonts are inherited. data-theme="dark" on an ancestor, or theme="dark" on the component, switches to the
dark defaults. Every element has a plycdn-* class, and className is added to the root. unstyled leaves
the styling entirely to you. Sizes are shown in binary units: 1 GB is 2^30 bytes, the same GB your bill uses.
10Troubleshooting
- Every call fails with
forbidden. Your backend has noIPlycdnStorageAuthorizer, or it refused that operation. The default refuses everything. invalid_response.baseUrlreaches a web page instead of your backend's plycdn routes.- The player says it cannot play this video. The browser can neither play the stream natively nor load the player's streaming code: check that your Content-Security-Policy allows the delivery hosts and your own scripts.