Stream or music, from the Building blocks group of the nertia component library.
npx shadcn@latest add https://ner.sh/r/block-stream.jsonThe recipe carries this block's layout and variant; the install brings the component.
{
"registries": {
"@nertia": "https://ner.sh/r/{name}.json"
}
}npx shadcn@latest add @nertia/block-streamnpm create nersh@latest my-site -- --site https://nertia.ai/components/building-blocks/element-block-stream/site.json scaffolds a project from it.{
"slug": "element-block-stream",
"mode": "builder",
"status": "draft",
"skinId": "zinc",
"composition": {
"id": "element-block-stream",
"sections": [
{
"id": "block-stream",
"instanceId": "stage-block-stream"
}
]
},
"copy": {
"stage-block-stream.url": "",
"stage-block-stream.aspect": "",
"stage-block-stream.muted": "",
"stage-block-stream.chat": "",
"stage-block-stream.offlineNote": ""
}
}
import type { Section } from '../types';
import { Component } from './Component';
import { schema } from './schema';
import { meta } from './meta';
import { writeCopy } from './writeCopy';
export const blockStream: Section = { meta, schema, Component, writeCopy };
'use client';
import { useEffect, useState } from 'react';
import type { SectionProps } from '../types';
import { Editable } from '@/components/site/EditableText';
import { MediaPlaceholder } from '@/components/foundation';
import type { MediaPlaceholderAspect } from '@/lib/foundation/contract';
import { compositionEditPath } from '@/lib/composition/compositionEditableCopy';
import { useSiteEmbedHosts } from '@/lib/foundation/sitePage';
import { cn } from '@/lib/utils';
import {
parseStreamUrl,
previewEmbedHosts,
streamChatSrc,
streamLinkHint,
streamParentHosts,
streamPlayerSrc,
streamTitle,
} from './parseStream';
const ASPECT_CLASS: Record<string, string> = {
'': 'aspect-video',
'4:3': 'aspect-[4/3]',
'21:9': 'aspect-[21/9]',
};
const PLACEHOLDER_ASPECT: Record<string, MediaPlaceholderAspect> = {
'': '16/9',
'4:3': '4/3',
'21:9': '16/9',
};
/** Audio widgets keep SoundCloud's own heights: the short track player, the tall list. */
const SOUNDCLOUD_HEIGHT_CLASS = { track: 'h-[166px]', playlist: 'h-[450px]', profile: 'h-[450px]' } as const;
const FRAME_CLASS =
'relative w-full overflow-hidden rounded-[var(--radius)] border border-[var(--card-border)] bg-[var(--card)]';
const IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox';
/** The page's own host, once mounted: empty on the server and the first client paint. */
function useRuntimeHost(): string {
const [host, setHost] = useState('');
useEffect(() => {
setHost(window.location.hostname);
}, []);
return host;
}
export function Component({ copy, instanceId }: SectionProps) {
const edit = (slotKey: string) => compositionEditPath(instanceId, slotKey);
const embedHosts = useSiteEmbedHosts();
const runtimeHost = useRuntimeHost();
const published = embedHosts !== null;
const source = parseStreamUrl(copy.url ?? '');
const aspect = (copy.aspect ?? '').trim();
const muted = (copy.muted ?? '').trim() !== 'off';
const wantsChat = (copy.chat ?? '').trim() === 'on';
const note = (copy.offlineNote ?? '').trim();
// The published page draws nothing until a stream is set; the editor shows where it goes.
if (!source && published) return null;
const noteLine = note ? (
<p className="site-type-sm text-[var(--muted-fg)]">
<Editable path={edit('offlineNote')!}>{note}</Editable>
</p>
) : null;
if (!source) {
const link = (copy.url ?? '').trim();
return (
<div data-section="block-stream" className="site-block-stream flex w-full flex-col gap-3">
<div className={cn(FRAME_CLASS, 'border-dashed')}>
<MediaPlaceholder aspect={PLACEHOLDER_ASPECT[aspect] ?? '16/9'} />
<span className="site-type-xs absolute inset-x-0 bottom-3 px-4 text-center text-[var(--muted-fg)]">
{link ? streamLinkHint(link) : 'Add your stream link, for example twitch.tv/purpsco'}
</span>
</div>
{noteLine}
</div>
);
}
const parents = streamParentHosts(embedHosts ?? previewEmbedHosts(), runtimeHost);
const playerSrc = streamPlayerSrc(source, { parents, muted });
const chatSrc = wantsChat ? streamChatSrc(source, parents) : null;
const title = streamTitle(source);
const frameSize =
source.provider === 'soundcloud' ? SOUNDCLOUD_HEIGHT_CLASS[source.kind] : ASPECT_CLASS[aspect] ?? ASPECT_CLASS[''];
return (
<div data-section="block-stream" className="site-block-stream flex w-full flex-col gap-3">
<div
data-stream-layout={chatSrc ? 'with-chat' : 'player'}
className={cn('grid w-full gap-4', chatSrc && '@cq-md/site:grid-cols-[minmax(0,1fr)_20rem]')}
>
<div className={cn(FRAME_CLASS, frameSize)}>
<iframe
src={playerSrc}
title={title}
loading="lazy"
sandbox={IFRAME_SANDBOX}
referrerPolicy="strict-origin-when-cross-origin"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
className="absolute inset-0 h-full w-full border-0"
/>
</div>
{chatSrc ? (
<div data-stream-chat="" className={cn(FRAME_CLASS, 'h-96 @cq-md/site:h-auto')}>
<iframe
src={chatSrc}
title={`Chat: ${title}`}
loading="lazy"
sandbox={`${IFRAME_SANDBOX} allow-forms allow-storage-access-by-user-activation`}
referrerPolicy="strict-origin-when-cross-origin"
className="absolute inset-0 h-full w-full border-0"
/>
</div>
) : null}
</div>
{noteLine}
</div>
);
}
import type { SectionCopySchemaField } from '../types';
export const schema: SectionCopySchemaField[] = [
{
key: 'url',
label: 'Stream link',
type: 'link',
placeholder: 'https://twitch.tv/purpsco',
helpText:
'A Twitch channel link or name (for example twitch.tv/purpsco), a YouTube channel link (youtube.com/channel/UC...) or video link, a Kick channel link, or a SoundCloud track, playlist or profile. A YouTube @handle cannot be embedded.',
},
{
key: 'aspect',
label: 'Shape',
type: 'text',
placeholder: '',
maxLength: 5,
helpText: 'The video player shape. A SoundCloud player keeps its own height.',
options: [
{ value: '', label: '16:9' },
{ value: '4:3', label: '4:3' },
{ value: '21:9', label: '21:9' },
],
},
{
key: 'muted',
label: 'Sound',
type: 'text',
placeholder: '',
maxLength: 3,
helpText: 'Browsers only start a stream on their own while it is muted. Sound on waits for a click to play.',
options: [
{ value: '', label: 'Muted' },
{ value: 'off', label: 'Sound on' },
],
},
{
key: 'chat',
label: 'Chat',
type: 'text',
placeholder: '',
maxLength: 3,
helpText: 'Shows the Twitch chat beside the player on a wide screen and below it on a phone. Twitch only.',
options: [
{ value: '', label: 'Off' },
{ value: 'on', label: 'On' },
],
},
{
key: 'offlineNote',
label: 'Note under the player',
type: 'text',
placeholder: 'Offline right now? Say when you are live next.',
maxLength: 120,
},
];
import type { SectionMeta } from '../types';
export const meta: SectionMeta = {
id: 'block-stream',
displayName: 'Stream player',
family: 'component',
tags: ['component', 'atom', 'stream', 'live', 'twitch', 'youtube', 'kick', 'soundcloud', 'music', 'audio', 'video', 'embed'],
bucket: 'atom',
};
import type { BrandContext } from '@/lib/brandContext';
export function writeCopy(_ctx: BrandContext) {
return {
url: '',
aspect: '',
muted: '',
chat: '',
offlineNote: '',
};
}
/**
* Live-stream links: which provider a pasted link belongs to, and the player and chat
* iframe sources for it. Pure, so the leaf, the editor and the tests share one reading.
*/
export type StreamSource =
| { provider: 'twitch'; channel: string }
| { provider: 'youtube'; channelId: string }
| { provider: 'youtube'; videoId: string }
| { provider: 'kick'; channel: string }
| { provider: 'soundcloud'; url: string; kind: SoundCloudKind };
/** A single track draws the short widget; a playlist or a whole profile the tall one. */
export type SoundCloudKind = 'track' | 'playlist' | 'profile';
/** Twitch logins: 4 to 25 letters, digits and underscores. A bare name is read as one. */
const TWITCH_CHANNEL = /^[a-z0-9_]{4,25}$/i;
const KICK_CHANNEL = /^[a-z0-9_-]{2,30}$/i;
const YOUTUBE_CHANNEL_ID = /^UC[a-z0-9_-]{22}$/i;
const YOUTUBE_VIDEO_ID = /^[a-z0-9_-]{11}$/i;
/** First path segments on twitch.tv that are site pages, not channels. */
const TWITCH_RESERVED = new Set(['directory', 'videos', 'search', 'settings', 'downloads', 'jobs', 'turbo', 'p', 'subscriptions', 'inventory', 'wallet', 'drops', 'friends', 'login', 'signup']);
/** First path segments on kick.com that are site pages, not channels. */
const KICK_RESERVED = new Set(['browse', 'categories', 'category', 'search', 'following', 'dashboard', 'video', 'clips', 'popout']);
const TWITCH_HOSTS = new Set(['twitch.tv', 'm.twitch.tv', 'go.twitch.tv', 'player.twitch.tv']);
const YOUTUBE_HOSTS = new Set(['youtube.com', 'm.youtube.com', 'youtu.be']);
const KICK_HOSTS = new Set(['kick.com', 'player.kick.com']);
const SOUNDCLOUD_HOSTS = new Set(['soundcloud.com', 'm.soundcloud.com']);
const SOUNDCLOUD_SEGMENT = /^[a-z0-9_-]{1,100}$/i;
/** First path segments on soundcloud.com that are site pages, not profiles. */
const SOUNDCLOUD_RESERVED = new Set(['discover', 'search', 'stream', 'you', 'upload', 'charts', 'pages', 'settings', 'messages', 'notifications', 'people', 'terms-of-use', 'feed', 'signin']);
function toUrl(raw: string): URL | null {
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw);
// `javascript:`, `data:` and the like: a scheme with no `//`, not a `host:port`.
if (!hasScheme && /^[a-z][a-z0-9+.-]*:(?!\d)/i.test(raw)) return null;
const withScheme = hasScheme ? raw : `https://${raw}`;
try {
const url = new URL(withScheme);
return url.protocol === 'https:' || url.protocol === 'http:' ? url : null;
} catch {
return null;
}
}
function segments(url: URL): string[] {
return url.pathname.split('/').filter(Boolean);
}
function parseTwitch(url: URL, host: string): StreamSource | null {
const channel = host === 'player.twitch.tv' ? url.searchParams.get('channel') ?? '' : segments(url)[0] ?? '';
if (!TWITCH_CHANNEL.test(channel) || TWITCH_RESERVED.has(channel.toLowerCase())) return null;
return { provider: 'twitch', channel: channel.toLowerCase() };
}
function parseYoutube(url: URL, host: string): StreamSource | null {
const parts = segments(url);
const videoId =
host === 'youtu.be'
? parts[0]
: url.pathname === '/watch'
? url.searchParams.get('v')
: parts[0] === 'live' || parts[0] === 'embed' || parts[0] === 'shorts'
? parts[1]
: null;
if (videoId && YOUTUBE_VIDEO_ID.test(videoId) && videoId !== 'live_stream') return { provider: 'youtube', videoId };
const channelId =
parts[0] === 'channel' ? parts[1] : parts[0] === 'embed' && parts[1] === 'live_stream' ? url.searchParams.get('channel') : null;
if (channelId && YOUTUBE_CHANNEL_ID.test(channelId)) return { provider: 'youtube', channelId };
return null;
}
function parseKick(url: URL, host: string): StreamSource | null {
const channel = segments(url)[0] ?? '';
if (!KICK_CHANNEL.test(channel)) return null;
if (host === 'kick.com' && KICK_RESERVED.has(channel.toLowerCase())) return null;
return { provider: 'kick', channel: channel.toLowerCase() };
}
function parseSoundCloud(url: URL): StreamSource | null {
const parts = segments(url);
if (parts.length === 0 || parts.length > 3 || !parts.every((part) => SOUNDCLOUD_SEGMENT.test(part))) return null;
if (SOUNDCLOUD_RESERVED.has(parts[0].toLowerCase())) return null;
const kind: SoundCloudKind | null =
parts.length === 1 ? 'profile' : parts.length === 2 && parts[1] !== 'sets' ? 'track' : parts.length === 3 && parts[1] === 'sets' ? 'playlist' : null;
if (!kind) return null;
return { provider: 'soundcloud', url: `https://soundcloud.com/${parts.join('/')}`, kind };
}
/**
* The stream a link points at: a Twitch channel (its URL or the bare name), a YouTube live
* channel (`/channel/UC...`) or video (`watch`, `live`, `youtu.be`), a Kick channel, or a
* SoundCloud track, playlist or profile. Null for anything else, including a YouTube
* `@handle`, which the live embed cannot address.
*/
export function parseStreamUrl(raw: string): StreamSource | null {
const value = raw.trim();
if (!value) return null;
if (TWITCH_CHANNEL.test(value)) return { provider: 'twitch', channel: value.toLowerCase() };
const url = toUrl(value);
if (!url) return null;
const host = url.hostname.toLowerCase().replace(/^www\./, '');
if (TWITCH_HOSTS.has(host)) return parseTwitch(url, host);
if (YOUTUBE_HOSTS.has(host)) return parseYoutube(url, host);
if (KICK_HOSTS.has(host)) return parseKick(url, host);
if (SOUNDCLOUD_HOSTS.has(host)) return parseSoundCloud(url);
return null;
}
const UNSUPPORTED_LINK_HINT = 'Use a Twitch, YouTube live, Kick or SoundCloud link';
const YOUTUBE_NAME_HINT =
'A YouTube @handle cannot be embedded. Use the channel link (youtube.com/channel/UC...) or a video link';
/**
* Why a link that is not blank draws no player, in words for the editor: a YouTube
* `@handle`, `/c/` or `/user/` link names a channel the live embed cannot address.
*/
export function streamLinkHint(raw: string): string {
const url = toUrl(raw.trim());
const host = url?.hostname.toLowerCase().replace(/^www\./, '') ?? '';
if (url && YOUTUBE_HOSTS.has(host)) {
const first = segments(url)[0] ?? '';
if (first.startsWith('@') || first === 'c' || first === 'user') return YOUTUBE_NAME_HINT;
}
return UNSUPPORTED_LINK_HINT;
}
/** A host as Twitch's `parent` wants it: lowercase, no scheme, port or path. Empty when it is not one. */
export function normalizeHost(raw: string): string {
const value = raw.trim().toLowerCase();
if (!value) return '';
const url = toUrl(value);
return url?.hostname ?? '';
}
/** Every host once, in first-seen order, normalized; blanks dropped. */
export function streamParentHosts(...lists: ReadonlyArray<readonly string[] | string>): string[] {
const out: string[] = [];
for (const list of lists) {
for (const raw of typeof list === 'string' ? [list] : list) {
const host = normalizeHost(raw);
if (host && !out.includes(host)) out.push(host);
}
}
return out;
}
/**
* The hosts the editor preview and catalogue thumbnails are drawn on. The leaf adds the
* page's own host once it mounts, so a preview deployment or LAN address still plays.
*/
export function previewEmbedHosts(): string[] {
return ['nertia.ai', 'www.nertia.ai', ...(process.env.NODE_ENV === 'production' ? [] : ['localhost'])];
}
function withParents(params: URLSearchParams, parents: readonly string[]): URLSearchParams {
for (const parent of parents) params.append('parent', parent);
return params;
}
/** The player iframe source. Twitch needs every host the page is served on as a `parent`. */
export function streamPlayerSrc(
source: StreamSource,
{ parents, muted }: { parents: readonly string[]; muted: boolean },
): string {
if (source.provider === 'twitch') {
const params = withParents(new URLSearchParams({ channel: source.channel }), parents);
params.set('muted', muted ? 'true' : 'false');
return `https://player.twitch.tv/?${params.toString()}`;
}
if (source.provider === 'soundcloud') {
const params = new URLSearchParams({ url: source.url, auto_play: 'false', visual: 'true' });
return `https://w.soundcloud.com/player/?${params.toString()}`;
}
if (source.provider === 'kick') {
const params = new URLSearchParams({ autoplay: muted ? 'true' : 'false', muted: muted ? 'true' : 'false' });
return `https://player.kick.com/${source.channel}?${params.toString()}`;
}
const params = muted ? new URLSearchParams({ autoplay: '1', mute: '1' }) : new URLSearchParams();
if ('channelId' in source) {
const live = new URLSearchParams({ channel: source.channelId });
params.forEach((value, key) => live.set(key, value));
return `https://www.youtube.com/embed/live_stream?${live.toString()}`;
}
const query = params.toString();
return `https://www.youtube.com/embed/${source.videoId}${query ? `?${query}` : ''}`;
}
/** The chat panel source, Twitch only; null for the providers whose chat cannot be embedded by channel. */
export function streamChatSrc(source: StreamSource, parents: readonly string[]): string | null {
if (source.provider !== 'twitch') return null;
const params = withParents(new URLSearchParams(), parents);
return `https://www.twitch.tv/embed/${source.channel}/chat?${params.toString()}`;
}
/** What the iframe is called for a screen reader. */
export function streamTitle(source: StreamSource): string {
if (source.provider === 'twitch') return `Live stream: ${source.channel} on Twitch`;
if (source.provider === 'kick') return `Live stream: ${source.channel} on Kick`;
if (source.provider === 'soundcloud') return 'Music on SoundCloud';
return 'Live stream on YouTube';
}