feat(site): source URL auto-detection for installation page (#619)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-02-26 09:19:28 -06:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 897a5af7f6
commit efd322ad07
9 changed files with 857 additions and 83 deletions
+5 -20
View File
@@ -17,8 +17,6 @@ export interface ImageRadioGroupProps<T = string> {
options: ImageRadioOption<T>[];
'aria-label': string;
className?: string;
size?: 'sm' | 'md';
labelPosition?: 'block' | 'inline';
}
export default function ImageRadioGroup<T extends string = string>({
@@ -27,21 +25,15 @@ export default function ImageRadioGroup<T extends string = string>({
options,
'aria-label': ariaLabel,
className,
size = 'md',
labelPosition = 'block',
}: ImageRadioGroupProps<T>) {
const boxSize = size === 'sm' ? 8 : 20;
return (
<RadioGroup
value={value}
onValueChange={(newValue) => onChange(newValue as T)}
aria-label={ariaLabel}
className={twMerge(clsx('grid auto-rows-min', size === 'sm' && 'gap-2', size === 'md' && 'gap-4'), className)}
className={twMerge(clsx('grid auto-rows-min gap-4'), className)}
style={{
gridTemplateColumns:
labelPosition === 'block'
? `repeat(auto-fill, minmax(calc(var(--spacing) * ${boxSize}), 1fr))`
: 'repeat(auto-fill, minmax(calc(var(--spacing) * 40), 1fr))',
gridTemplateColumns: `repeat(auto-fill, minmax(calc(var(--spacing) * 20), 1fr))`,
}}
>
{options.map((option) => {
@@ -52,18 +44,15 @@ export default function ImageRadioGroup<T extends string = string>({
<Radio.Root
value={option.value}
className={clsx(
'group inline-flex items-center gap-2',
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
labelPosition === 'block' ? 'flex-col' : 'flex-row'
'group inline-flex items-center gap-2 flex-col',
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
)}
key={option.value}
disabled={isDisabled}
>
<div
className={clsx(
'relative flex items-center justify-center aspect-square',
size === 'sm' && 'rounded-lg',
size === 'md' && 'rounded-xl',
'relative flex items-center justify-center aspect-square w-full rounded-xl',
'border border-light-40 dark:border-dark-80',
isSelected
? 'bg-light-60 dark:bg-dark-90'
@@ -71,10 +60,6 @@ export default function ImageRadioGroup<T extends string = string>({
'bg-light-80 dark:bg-dark-100 group-intent:bg-light-60/50 dark:group-intent:bg-dark-90/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow/50'
)}
style={{
width: labelPosition === 'block' ? '100%' : undefined,
height: labelPosition === 'inline' ? `calc(var(--spacing) * ${boxSize})` : undefined,
}}
>
<Radio.Indicator className="sr-only" />
<div className="flex items-center justify-center w-full h-full">{option.image}</div>
@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import type { Renderer, Skin, UseCase } from '@/stores/installation';
import { installMethod, muxPlaybackId, renderer, skin, useCase } from '@/stores/installation';
import { installMethod, muxPlaybackId, renderer, skin, sourceUrl, useCase } from '@/stores/installation';
function getRendererTag(renderer: Renderer): string {
const map: Record<Renderer, string> = {
@@ -48,7 +48,7 @@ function getSkinTag(useCase: UseCase, skin: Skin): string {
return map[skin];
}
function getRendererElement(renderer: Renderer, playbackId: string | null): string {
function getRendererElement(renderer: Renderer, playbackId: string | null, url: string): string {
const tag = getRendererTag(renderer);
// When renderer is a mux variant and we have a playback ID, use playback-id attribute
@@ -56,14 +56,20 @@ function getRendererElement(renderer: Renderer, playbackId: string | null): stri
return `<${tag} playback-id="${playbackId}"></${tag}>`;
}
// Default: use src attribute placeholder
return `<${tag} src="..."></${tag}>`;
const src = url.trim() || '...';
return `<${tag} src="${src}"></${tag}>`;
}
function generateHTMLCode(useCase: UseCase, skin: Skin, renderer: Renderer, playbackId: string | null): string {
function generateHTMLCode(
useCase: UseCase,
skin: Skin,
renderer: Renderer,
playbackId: string | null,
url: string
): string {
const providerTag = getProviderTag(useCase);
const skinTag = getSkinTag(useCase, skin);
const rendererElement = getRendererElement(renderer, playbackId);
const rendererElement = getRendererElement(renderer, playbackId, url);
return `<!--
The PlayerProvider passes state between the UI components
@@ -112,6 +118,7 @@ export default function HTMLUsageCodeBlock() {
const $renderer = useStore(renderer);
const $muxPlaybackId = useStore(muxPlaybackId);
const $installMethod = useStore(installMethod);
const $sourceUrl = useStore(sourceUrl);
return (
<>
@@ -134,7 +141,7 @@ export default function HTMLUsageCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="html" initial>
<ClientCode code={generateHTMLCode($useCase, $skin, $renderer, $muxPlaybackId)} lang="html" />
<ClientCode code={generateHTMLCode($useCase, $skin, $renderer, $muxPlaybackId, $sourceUrl)} lang="html" />
</TabsPanel>
</TabsRoot>
</>
@@ -9,7 +9,7 @@ import MuxUploader, {
MuxUploaderStatus,
} from '@mux/mux-uploader-react';
import { useCallback, useRef, useState } from 'react';
import { muxPlaybackId, renderer } from '@/stores/installation';
import { muxPlaybackId, renderer, sourceUrl } from '@/stores/installation';
import { initiateAuthPopup } from '@/utils/mux/auth-flow';
import { pollForPlaybackId } from '@/utils/mux/polling';
import type { UploaderState } from './UploaderOverlay';
@@ -160,6 +160,7 @@ export default function MuxUploaderPanel() {
setState('ready');
renderer.set('mux-video');
muxPlaybackId.set(result.playbackId);
sourceUrl.set(`https://stream.mux.com/${result.playbackId}.m3u8`);
}, [uploadId]);
/** Resets uploader to try again after error */
@@ -2,12 +2,20 @@ import { useStore } from '@nanostores/react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import type { Renderer } from '@/stores/installation';
import { muxPlaybackId, renderer } from '@/stores/installation';
import { muxPlaybackId, renderer, sourceUrl } from '@/stores/installation';
function generateUsageCode(renderer: Renderer, playbackId: string | null): string {
function generateUsageCode(renderer: Renderer, playbackId: string | null, url: string): string {
const isMuxWithPlaybackId =
(renderer === 'mux-video' || renderer === 'mux-audio' || renderer === 'mux-background-video') && playbackId;
const playerProp = isMuxWithPlaybackId ? `playbackId="${playbackId}"` : 'src="https://example.com/video.mp4"';
let playerProp: string;
if (isMuxWithPlaybackId) {
playerProp = `playbackId="${playbackId}"`;
} else if (url.trim()) {
playerProp = `src="${url.trim()}"`;
} else {
playerProp = 'src="https://example.com/video.mp4"';
}
return `import { MyPlayer } from '../components/player';
@@ -24,6 +32,7 @@ export const HomePage = () => {
export default function ReactUsageCodeBlock() {
const $renderer = useStore(renderer);
const $muxPlaybackId = useStore(muxPlaybackId);
const $sourceUrl = useStore(sourceUrl);
return (
<TabsRoot maxWidth={false}>
@@ -33,7 +42,7 @@ export default function ReactUsageCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="react" initial>
<ClientCode code={generateUsageCode($renderer, $muxPlaybackId)} lang="tsx" />
<ClientCode code={generateUsageCode($renderer, $muxPlaybackId, $sourceUrl)} lang="tsx" />
</TabsPanel>
</TabsRoot>
);
@@ -1,67 +1,113 @@
import { useStore } from '@nanostores/react';
import { Box } from 'lucide-react';
import { useEffect } from 'react';
import type { ImageRadioOption } from '@/components/ImageRadioGroup';
import ImageRadioGroup from '@/components/ImageRadioGroup';
import type { Renderer } from '@/stores/installation';
import { renderer, useCase } from '@/stores/installation';
import { Select, type SelectOption } from '@/components/Select';
import type { Renderer, UseCase } from '@/stores/installation';
import { muxPlaybackId, renderer, sourceUrl, useCase, VALID_RENDERERS } from '@/stores/installation';
import { articleFor, detectRenderer, extractMuxPlaybackId } from '@/utils/installation/detect-renderer';
const VIDEO_RENDERERS: ImageRadioOption<Renderer>[] = [
{ value: 'html5-video', label: 'HTML5 Video', image: <Box size={16} /> },
...(
[
{ value: 'cloudflare', label: 'Cloudflare', image: <Box size={16} /> },
{ value: 'dash', label: 'DASH', image: <Box size={16} /> },
{ value: 'hls', label: 'HLS', image: <Box size={16} /> },
{ value: 'jwplayer', label: 'JW Player', image: <Box size={16} /> },
{ value: 'mux-video', label: 'Mux', image: <Box size={16} /> },
// { value: 'shaka', label: 'Shaka', image: <Box size={16} /> },
{ value: 'vimeo', label: 'Vimeo', image: <Box size={16} /> },
{ value: 'wistia', label: 'Wistia', image: <Box size={16} /> },
{ value: 'youtube', label: 'YouTube', image: <Box size={16} /> },
] satisfies ImageRadioOption<Renderer>[]
).sort((a, b) => a.label.localeCompare(b.label)),
];
const RENDERER_LABELS: Record<Renderer, string> = {
'background-video': 'Background Video',
cloudflare: 'Cloudflare',
dash: 'DASH',
hls: 'HLS',
'html5-audio': 'HTML5 Audio',
'html5-video': 'HTML5 Video',
jwplayer: 'JW Player',
'mux-audio': 'Mux',
'mux-background-video': 'Mux Background Video',
'mux-video': 'Mux',
spotify: 'Spotify',
vimeo: 'Vimeo',
wistia: 'Wistia',
youtube: 'YouTube',
};
const AUDIO_RENDERERS: ImageRadioOption<Renderer>[] = [
{ value: 'html5-audio', label: 'HTML5 Audio', image: <Box size={16} /> },
{ value: 'mux-audio', label: 'Mux', image: <Box size={16} /> },
{ value: 'spotify', label: 'Spotify', image: <Box size={16} /> },
];
function buildOptions(useCase: UseCase): SelectOption<Renderer>[] {
return VALID_RENDERERS[useCase].map((r) => ({
value: r,
label: RENDERER_LABELS[r],
}));
}
const BACKGROUND_VIDEO_RENDERERS: ImageRadioOption<Renderer>[] = [
{ value: 'background-video', label: 'Background Video', image: <Box size={16} /> },
{ value: 'mux-background-video', label: 'Mux Background Video', image: <Box size={16} /> },
];
/** URL input and renderer dropdown for manual selection */
export default function RendererSelect() {
const $renderer = useStore(renderer);
const $useCase = useStore(useCase);
const $sourceUrl = useStore(sourceUrl);
const options =
$useCase === 'default-audio'
? AUDIO_RENDERERS
: $useCase === 'background-video'
? BACKGROUND_VIDEO_RENDERERS
: VIDEO_RENDERERS;
const options = buildOptions($useCase);
const detection = detectRenderer($sourceUrl, $useCase);
// Auto-switch renderer when use case changes and current renderer is invalid
// Auto-select renderer when detection or use case changes
useEffect(() => {
const validValues = options.map((o) => o.value);
if (!validValues.includes($renderer)) {
renderer.set(options[0].value);
if (detection) {
renderer.set(detection.renderer);
const playbackId = extractMuxPlaybackId($sourceUrl);
if (playbackId) {
muxPlaybackId.set(playbackId);
}
} else {
// No valid detection — ensure current renderer is valid for use case
const current = renderer.get();
const validRenderers = VALID_RENDERERS[$useCase];
if (!validRenderers.includes(current)) {
renderer.set(validRenderers[0]!);
}
}
}, [$useCase]);
}, [detection, $sourceUrl, $useCase]);
const showDetectionMatch = $sourceUrl.trim() && detection && detection.renderer === $renderer;
const showDetectionSuggestion = $sourceUrl.trim() && detection && detection.renderer !== $renderer;
const showNoMatch = $sourceUrl.trim() && !detection;
return (
<ImageRadioGroup
value={$renderer}
onChange={(value) => renderer.set(value)}
options={options}
aria-label="Select renderer"
size="sm"
labelPosition="inline"
/>
<div className="flex flex-col gap-3">
{/* URL input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="source-url-input" className="text-sm text-dark-40 dark:text-light-40">
Enter the URL to a video to auto-detect
</label>
<input
id="source-url-input"
type="url"
value={$sourceUrl}
onChange={(e) => sourceUrl.set(e.target.value)}
placeholder="https://..."
className="bg-light-60 dark:bg-dark-90 dark:text-light-100 border border-light-40 dark:border-dark-80 rounded-lg text-sm p-2"
/>
</div>
{/* Select dropdown */}
<div className="flex flex-col gap-1.5">
<label htmlFor="renderer-select" className="text-sm text-dark-40 dark:text-light-40 flex flex-wrap gap-1">
{showDetectionMatch ? (
`This looks like ${articleFor(detection.renderer)} ${detection.label} link`
) : showDetectionSuggestion ? (
<>
This looks like {articleFor(detection.renderer)} {detection.label} link.
<button
type="button"
onClick={() => renderer.set(detection.renderer)}
className="cursor-pointer underline intent:no-underline"
>
Select {detection.label}
</button>
</>
) : showNoMatch ? (
`We couldn't detect the source type — select manually below`
) : (
`or select manually`
)}
</label>
<Select
value={$renderer}
onChange={(value) => {
if (value) renderer.set(value);
}}
options={options}
aria-label="Select renderer"
/>
</div>
</div>
);
}