diff --git a/internal/design/site/source-url-detection.md b/internal/design/site/source-url-detection.md new file mode 100644 index 00000000..69dd1a95 --- /dev/null +++ b/internal/design/site/source-url-detection.md @@ -0,0 +1,180 @@ +--- +status: decided +date: 2026-02-26 +--- + +# Source URL Auto-Detection for Installation Page + +## Decision + +Replace the radio grid source picker on the installation page with a URL input + select dropdown. +Users paste a media URL, the system auto-detects the source type, and the generated code reflects +their actual URL. + +## Context + +The installation page wizard walks users through: framework → use case → skin → source → code output. + +The source step currently shows a radio grid of icons (HTML5 Video, YouTube, HLS, etc.) via +`ImageRadioGroup`. This requires users to already know what source type they need. A URL-first +approach is more intuitive — paste the URL you have, we'll figure out the rest. + +## Spec + +### Layout + +The left column of `RendererPicker` changes from a radio grid to: + +``` +"Enter the URL to a video to auto-detect" +┌─────────────────────────────────────┐ +│ https://... │ +└─────────────────────────────────────┘ + +"This looks like a YouTube link. Select YouTube" ← dynamic label above dropdown +┌─ YouTube ──────────────────────── ▾ ┐ +└─────────────────────────────────────┘ +``` + +The label above the dropdown is dynamic — it changes based on detection state (see +Suggestion Text below). When no URL is entered, it reads "or select manually". + +The right column (Mux uploader panel) stays as-is. + +### Detection Rules + +Given a URL, check **domain first**, then **file extension**. Domain always wins over extension. + +| Signal | Renderer | Notes | +|--------|----------|-------| +| `youtube.com`, `youtu.be` | `youtube` | | +| `vimeo.com` | `vimeo` | | +| `stream.mux.com`, `mux.com` | `mux-video` / `mux-audio` | Depends on use case | +| `open.spotify.com` | `spotify` | | +| `watch.videodelivery.net`, `videodelivery.net`, `cloudflarestream.com` | `cloudflare` | | +| `cdn.jwplayer.com`, `content.jwplatform.com` | `jwplayer` | | +| `fast.wistia.com`, `fast.wistia.net`, `*.wistia.com` | `wistia` | | +| `.m3u8` extension | `hls` | | +| `.mpd` extension | `dash` | | +| `.mp4`, `.webm`, `.mov`, `.ogv` | `html5-video` | | +| `.mp3`, `.wav`, `.ogg`, `.flac`, `.aac` | `html5-audio` | | +| No match | `null` | Show "select manually" message | + +### Mux Playback ID Extraction + +Mux stream URLs follow the pattern `https://stream.mux.com/{PLAYBACK_ID}.m3u8`. +Extract the playback ID and store it in the `muxPlaybackId` nanostore so code generation +uses `playback-id="..."` instead of `src="..."`. + +### Use Case Filtering + +Detection is filtered by the active use case. If the detected renderer isn't valid for the +current use case (e.g., YouTube URL + audio use case), show: + +> "No match for audio sources — select manually" + +...and don't auto-select anything. + +When the use case changes and a URL is present, re-run detection. + +### Mux Upload Integration + +When a Mux upload completes and a playback ID is available, construct +`https://stream.mux.com/{PLAYBACK_ID}.m3u8` and set it as the URL input value. +This triggers detection, which identifies it as Mux and extracts the playback ID. + +### Select Dropdown Behavior + +- Populated with the same renderer options currently in the radio grid, filtered by use case. +- When URL detection auto-selects a renderer, the dropdown reflects the selection. +- When the user manually overrides via the dropdown, the label changes to show the + detection text with an inline "Select YouTube" link so they can revert to the + detected choice. +- Manually picking from the dropdown does **not** clear the URL input. + +### Code Output + +The user's URL is injected into generated code: + +- **HTML**: `` +- **React**: `` +- **Mux special case**: Uses `playback-id="..."` instead of `src="..."` +- **Empty URL**: Falls back to `src="..."` placeholder (current behavior) + +### Suggestion Text + +The suggestion text replaces the dropdown label (not a separate area). Always hedge — +treat every detection as uncertain: + +- No URL entered: _"or select manually"_ +- Match found, matches current selection: _"This looks like a/an [Source] link"_ +- Match found, differs from selection: _"This looks like a/an [Source] link."_ + underlined **"Select [Source]"** link +- No match: _"We couldn't detect the source type — select manually below"_ + +Always auto-select the detected renderer (when valid for the use case), even when hedging. + +#### Determiner Logic + +The article before the label ("a" vs "an") is chosen by the opening **sound** of the label, +not its first letter. Use "an" before vowel sounds, "a" before consonant sounds. + +Implementation: a `Record` maps every renderer to its article. Using +`Record` ensures a compile-time error if a renderer is added to the `Renderer` +union without specifying its article. A helper `articleFor(renderer: Renderer): "a" | "an"` +looks up the record. + +```ts +const RENDERER_ARTICLES: Record = { + 'background-video': 'a', + 'cloudflare': 'a', + 'dash': 'a', + 'hls': 'an', + 'html5-audio': 'an', + 'html5-video': 'an', + 'jwplayer': 'a', + 'mux-audio': 'a', + 'mux-background-video': 'a', + 'mux-video': 'a', + 'spotify': 'a', + 'vimeo': 'a', + 'wistia': 'a', + 'youtube': 'a', +}; +``` + +## New State + +Add to `installation.ts`: + +```ts +export const sourceUrl = atom(''); +``` + +This is read by code generation components to inject the real URL into output. + +## Files to Change + +| File | Change | +|------|--------| +| `site/src/stores/installation.ts` | Add `sourceUrl` atom | +| `site/src/components/installation/RendererSelect.tsx` | Rewrite: URL input + select dropdown + detection logic | +| `site/src/components/installation/RendererPicker.tsx` | Update heading text from "Select your source" | +| `site/src/components/installation/MuxUploaderPanel.tsx` | On upload complete, set `sourceUrl` to Mux stream URL | +| `site/src/components/installation/HTMLUsageCodeBlock.tsx` | Read `sourceUrl`; use as `src` value when non-empty | +| `site/src/components/installation/ReactUsageCodeBlock.tsx` | Read `sourceUrl`; use as `src` value when non-empty | + +New files: + +| File | Purpose | +|------|---------| +| `site/src/utils/detectRenderer.ts` | Pure function: URL → `{ renderer, label }` or `null` | +| `site/src/utils/__tests__/detectRenderer.test.ts` | Tests for detection logic | + +## Alternatives Considered + +- **Keep the radio grid, add URL input above it** — More UI clutter, two selection mechanisms + visible at once, confusing which takes priority. +- **Auto-detect only, no manual dropdown** — Some sources can't be detected from URL alone + (e.g., user hasn't decided yet, or the URL is unusual). +- **Confidence levels (high/low) with different UX** — Adds complexity. Hedging the language + universally is simpler and sufficient until we see user confusion. diff --git a/site/src/components/ImageRadioGroup.tsx b/site/src/components/ImageRadioGroup.tsx index 45c5d5e2..cf54c3fd 100644 --- a/site/src/components/ImageRadioGroup.tsx +++ b/site/src/components/ImageRadioGroup.tsx @@ -17,8 +17,6 @@ export interface ImageRadioGroupProps { options: ImageRadioOption[]; 'aria-label': string; className?: string; - size?: 'sm' | 'md'; - labelPosition?: 'block' | 'inline'; } export default function ImageRadioGroup({ @@ -27,21 +25,15 @@ export default function ImageRadioGroup({ options, 'aria-label': ariaLabel, className, - size = 'md', - labelPosition = 'block', }: ImageRadioGroupProps) { - const boxSize = size === 'sm' ? 8 : 20; return ( 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({
({ '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, - }} >
{option.image}
diff --git a/site/src/components/installation/HTMLUsageCodeBlock.tsx b/site/src/components/installation/HTMLUsageCodeBlock.tsx index ea265444..9af0538d 100644 --- a/site/src/components/installation/HTMLUsageCodeBlock.tsx +++ b/site/src/components/installation/HTMLUsageCodeBlock.tsx @@ -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 = { @@ -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}">`; } - // Default: use src attribute placeholder - return `<${tag} src="...">`; + const src = url.trim() || '...'; + return `<${tag} src="${src}">`; } -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 `