mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.6
parent
897a5af7f6
commit
efd322ad07
@@ -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**: `<youtube-video src="https://youtube.com/watch?v=abc123"></youtube-video>`
|
||||
- **React**: `<MyPlayer src="https://youtube.com/watch?v=abc123" />`
|
||||
- **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<Renderer, "a" | "an">` maps every renderer to its article. Using
|
||||
`Record<Renderer, ...>` 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<Renderer, 'a' | 'an'> = {
|
||||
'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<string>('');
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type UseCase = 'default-video' | 'default-audio' | 'background-video';
|
||||
export const renderer = atom<Renderer>('html5-video');
|
||||
export const skin = atom<Skin>('video');
|
||||
export const useCase = atom<UseCase>('default-video');
|
||||
export const sourceUrl = atom<string>('');
|
||||
|
||||
export type InstallMethod = 'cdn' | 'npm' | 'pnpm' | 'yarn' | 'bun';
|
||||
|
||||
@@ -31,3 +32,9 @@ export const installMethod = atom<InstallMethod>('cdn');
|
||||
|
||||
/** Mux playback ID from successful upload (used by code generation) */
|
||||
export const muxPlaybackId = atom<string | null>(null);
|
||||
|
||||
export const VALID_RENDERERS: Record<UseCase, Renderer[]> = {
|
||||
'default-video': ['html5-video', 'cloudflare', 'dash', 'hls', 'jwplayer', 'mux-video', 'vimeo', 'wistia', 'youtube'],
|
||||
'default-audio': ['html5-audio', 'mux-audio', 'spotify'],
|
||||
'background-video': ['background-video', 'mux-background-video'],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { articleFor, detectRenderer, extractMuxPlaybackId, isRendererValidForUseCase } from '../detect-renderer';
|
||||
|
||||
describe('detectRenderer', () => {
|
||||
describe('domain rules', () => {
|
||||
it('detects youtube.com', () => {
|
||||
expect(detectRenderer('https://www.youtube.com/watch?v=abc123', 'default-video')).toEqual({
|
||||
renderer: 'youtube',
|
||||
label: 'YouTube',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects youtu.be', () => {
|
||||
expect(detectRenderer('https://youtu.be/abc123', 'default-video')).toEqual({
|
||||
renderer: 'youtube',
|
||||
label: 'YouTube',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects m.youtube.com', () => {
|
||||
expect(detectRenderer('https://m.youtube.com/watch?v=abc123', 'default-video')).toEqual({
|
||||
renderer: 'youtube',
|
||||
label: 'YouTube',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects vimeo.com', () => {
|
||||
expect(detectRenderer('https://vimeo.com/123456', 'default-video')).toEqual({
|
||||
renderer: 'vimeo',
|
||||
label: 'Vimeo',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects player.vimeo.com', () => {
|
||||
expect(detectRenderer('https://player.vimeo.com/video/123456', 'default-video')).toEqual({
|
||||
renderer: 'vimeo',
|
||||
label: 'Vimeo',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects stream.mux.com for default-video', () => {
|
||||
expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'default-video')).toEqual({
|
||||
renderer: 'mux-video',
|
||||
label: 'Mux',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects stream.mux.com for default-audio', () => {
|
||||
expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'default-audio')).toEqual({
|
||||
renderer: 'mux-audio',
|
||||
label: 'Mux',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects stream.mux.com for background-video', () => {
|
||||
expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'background-video')).toEqual({
|
||||
renderer: 'mux-background-video',
|
||||
label: 'Mux',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects open.spotify.com', () => {
|
||||
expect(detectRenderer('https://open.spotify.com/track/abc123', 'default-audio')).toEqual({
|
||||
renderer: 'spotify',
|
||||
label: 'Spotify',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects watch.videodelivery.net', () => {
|
||||
expect(detectRenderer('https://watch.videodelivery.net/abc123', 'default-video')).toEqual({
|
||||
renderer: 'cloudflare',
|
||||
label: 'Cloudflare',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects videodelivery.net', () => {
|
||||
expect(detectRenderer('https://videodelivery.net/abc123', 'default-video')).toEqual({
|
||||
renderer: 'cloudflare',
|
||||
label: 'Cloudflare',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects cloudflarestream.com', () => {
|
||||
expect(detectRenderer('https://cloudflarestream.com/abc123/manifest/video.m3u8', 'default-video')).toEqual({
|
||||
renderer: 'cloudflare',
|
||||
label: 'Cloudflare',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects cdn.jwplayer.com', () => {
|
||||
expect(detectRenderer('https://cdn.jwplayer.com/players/abc123.html', 'default-video')).toEqual({
|
||||
renderer: 'jwplayer',
|
||||
label: 'JW Player',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects content.jwplatform.com', () => {
|
||||
expect(detectRenderer('https://content.jwplatform.com/videos/abc123.mp4', 'default-video')).toEqual({
|
||||
renderer: 'jwplayer',
|
||||
label: 'JW Player',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects fast.wistia.com', () => {
|
||||
expect(detectRenderer('https://fast.wistia.com/medias/abc123', 'default-video')).toEqual({
|
||||
renderer: 'wistia',
|
||||
label: 'Wistia',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects fast.wistia.net', () => {
|
||||
expect(detectRenderer('https://fast.wistia.net/medias/abc123', 'default-video')).toEqual({
|
||||
renderer: 'wistia',
|
||||
label: 'Wistia',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects *.wistia.com', () => {
|
||||
expect(detectRenderer('https://mycompany.wistia.com/medias/abc123', 'default-video')).toEqual({
|
||||
renderer: 'wistia',
|
||||
label: 'Wistia',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extension rules', () => {
|
||||
it('detects .m3u8 as HLS', () => {
|
||||
expect(detectRenderer('https://example.com/video.m3u8', 'default-video')).toEqual({
|
||||
renderer: 'hls',
|
||||
label: 'HLS',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .mpd as DASH', () => {
|
||||
expect(detectRenderer('https://example.com/video.mpd', 'default-video')).toEqual({
|
||||
renderer: 'dash',
|
||||
label: 'DASH',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .mp4 as HTML5 Video', () => {
|
||||
expect(detectRenderer('https://example.com/video.mp4', 'default-video')).toEqual({
|
||||
renderer: 'html5-video',
|
||||
label: 'HTML5 Video',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .webm as HTML5 Video', () => {
|
||||
expect(detectRenderer('https://example.com/video.webm', 'default-video')).toEqual({
|
||||
renderer: 'html5-video',
|
||||
label: 'HTML5 Video',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .mov as HTML5 Video', () => {
|
||||
expect(detectRenderer('https://example.com/video.mov', 'default-video')).toEqual({
|
||||
renderer: 'html5-video',
|
||||
label: 'HTML5 Video',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .ogv as HTML5 Video', () => {
|
||||
expect(detectRenderer('https://example.com/video.ogv', 'default-video')).toEqual({
|
||||
renderer: 'html5-video',
|
||||
label: 'HTML5 Video',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .mp3 as HTML5 Audio', () => {
|
||||
expect(detectRenderer('https://example.com/audio.mp3', 'default-audio')).toEqual({
|
||||
renderer: 'html5-audio',
|
||||
label: 'HTML5 Audio',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .wav as HTML5 Audio', () => {
|
||||
expect(detectRenderer('https://example.com/audio.wav', 'default-audio')).toEqual({
|
||||
renderer: 'html5-audio',
|
||||
label: 'HTML5 Audio',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .ogg as HTML5 Audio', () => {
|
||||
expect(detectRenderer('https://example.com/audio.ogg', 'default-audio')).toEqual({
|
||||
renderer: 'html5-audio',
|
||||
label: 'HTML5 Audio',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .flac as HTML5 Audio', () => {
|
||||
expect(detectRenderer('https://example.com/audio.flac', 'default-audio')).toEqual({
|
||||
renderer: 'html5-audio',
|
||||
label: 'HTML5 Audio',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects .aac as HTML5 Audio', () => {
|
||||
expect(detectRenderer('https://example.com/audio.aac', 'default-audio')).toEqual({
|
||||
renderer: 'html5-audio',
|
||||
label: 'HTML5 Audio',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips query params when checking extension', () => {
|
||||
expect(detectRenderer('https://example.com/video.mp4?token=abc', 'default-video')).toEqual({
|
||||
renderer: 'html5-video',
|
||||
label: 'HTML5 Video',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('domain priority over extension', () => {
|
||||
it('stream.mux.com with .m3u8 detects as Mux, not HLS', () => {
|
||||
expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'default-video')).toEqual({
|
||||
renderer: 'mux-video',
|
||||
label: 'Mux',
|
||||
});
|
||||
});
|
||||
|
||||
it('content.jwplatform.com with .mp4 detects as JW Player, not HTML5 Video', () => {
|
||||
expect(detectRenderer('https://content.jwplatform.com/videos/abc.mp4', 'default-video')).toEqual({
|
||||
renderer: 'jwplayer',
|
||||
label: 'JW Player',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL without protocol', () => {
|
||||
it('auto-prepends https://', () => {
|
||||
expect(detectRenderer('youtube.com/watch?v=abc', 'default-video')).toEqual({
|
||||
renderer: 'youtube',
|
||||
label: 'YouTube',
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-prepends https:// for extension-based detection', () => {
|
||||
expect(detectRenderer('example.com/video.mp4', 'default-video')).toEqual({
|
||||
renderer: 'html5-video',
|
||||
label: 'HTML5 Video',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid input', () => {
|
||||
it('returns null for empty string', () => {
|
||||
expect(detectRenderer('', 'default-video')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for whitespace', () => {
|
||||
expect(detectRenderer(' ', 'default-video')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for garbage input', () => {
|
||||
expect(detectRenderer('not a url at all!!!', 'default-video')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown domain and no extension', () => {
|
||||
expect(detectRenderer('https://example.com/page', 'default-video')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('use-case filtering', () => {
|
||||
it('returns null for YouTube with audio use case', () => {
|
||||
expect(detectRenderer('https://www.youtube.com/watch?v=abc', 'default-audio')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for YouTube with background-video use case', () => {
|
||||
expect(detectRenderer('https://www.youtube.com/watch?v=abc', 'background-video')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for Spotify with default-video use case', () => {
|
||||
expect(detectRenderer('https://open.spotify.com/track/abc', 'default-video')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for .mp3 with default-video use case', () => {
|
||||
expect(detectRenderer('https://example.com/audio.mp3', 'default-video')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for .mp4 with default-audio use case', () => {
|
||||
expect(detectRenderer('https://example.com/video.mp4', 'default-audio')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMuxPlaybackId', () => {
|
||||
it('extracts playback ID from stream.mux.com URL with .m3u8', () => {
|
||||
expect(extractMuxPlaybackId('https://stream.mux.com/abc123.m3u8')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('extracts playback ID from stream.mux.com URL without extension', () => {
|
||||
expect(extractMuxPlaybackId('https://stream.mux.com/abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('returns null for non-Mux URL', () => {
|
||||
expect(extractMuxPlaybackId('https://example.com/abc123.m3u8')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for mux.com (not stream.mux.com)', () => {
|
||||
expect(extractMuxPlaybackId('https://mux.com/abc123')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty URL', () => {
|
||||
expect(extractMuxPlaybackId('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid URL', () => {
|
||||
expect(extractMuxPlaybackId('not a url')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for stream.mux.com with no path segment', () => {
|
||||
expect(extractMuxPlaybackId('https://stream.mux.com/')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('articleFor', () => {
|
||||
it('returns "an" for hls', () => {
|
||||
expect(articleFor('hls')).toBe('an');
|
||||
});
|
||||
|
||||
it('returns "an" for html5-video', () => {
|
||||
expect(articleFor('html5-video')).toBe('an');
|
||||
});
|
||||
|
||||
it('returns "an" for html5-audio', () => {
|
||||
expect(articleFor('html5-audio')).toBe('an');
|
||||
});
|
||||
|
||||
it('returns "a" for youtube', () => {
|
||||
expect(articleFor('youtube')).toBe('a');
|
||||
});
|
||||
|
||||
it('returns "a" for vimeo', () => {
|
||||
expect(articleFor('vimeo')).toBe('a');
|
||||
});
|
||||
|
||||
it('returns "a" for mux-video', () => {
|
||||
expect(articleFor('mux-video')).toBe('a');
|
||||
});
|
||||
|
||||
it('returns "a" for dash', () => {
|
||||
expect(articleFor('dash')).toBe('a');
|
||||
});
|
||||
|
||||
it('returns "a" for jwplayer', () => {
|
||||
expect(articleFor('jwplayer')).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRendererValidForUseCase', () => {
|
||||
it('html5-video is valid for default-video', () => {
|
||||
expect(isRendererValidForUseCase('html5-video', 'default-video')).toBe(true);
|
||||
});
|
||||
|
||||
it('youtube is valid for default-video', () => {
|
||||
expect(isRendererValidForUseCase('youtube', 'default-video')).toBe(true);
|
||||
});
|
||||
|
||||
it('youtube is not valid for default-audio', () => {
|
||||
expect(isRendererValidForUseCase('youtube', 'default-audio')).toBe(false);
|
||||
});
|
||||
|
||||
it('html5-audio is valid for default-audio', () => {
|
||||
expect(isRendererValidForUseCase('html5-audio', 'default-audio')).toBe(true);
|
||||
});
|
||||
|
||||
it('spotify is valid for default-audio', () => {
|
||||
expect(isRendererValidForUseCase('spotify', 'default-audio')).toBe(true);
|
||||
});
|
||||
|
||||
it('background-video is valid for background-video', () => {
|
||||
expect(isRendererValidForUseCase('background-video', 'background-video')).toBe(true);
|
||||
});
|
||||
|
||||
it('html5-video is not valid for default-audio', () => {
|
||||
expect(isRendererValidForUseCase('html5-video', 'default-audio')).toBe(false);
|
||||
});
|
||||
|
||||
it('html5-video is not valid for background-video', () => {
|
||||
expect(isRendererValidForUseCase('html5-video', 'background-video')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Renderer, UseCase } from '@/stores/installation';
|
||||
import { VALID_RENDERERS } from '@/stores/installation';
|
||||
|
||||
export interface DetectionResult {
|
||||
renderer: Renderer;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const DOMAIN_RULES: Array<{ match: (hostname: string) => boolean; renderer: Renderer; label: string }> = [
|
||||
{
|
||||
match: (h) => h === 'youtube.com' || h === 'www.youtube.com' || h === 'youtu.be' || h === 'm.youtube.com',
|
||||
renderer: 'youtube',
|
||||
label: 'YouTube',
|
||||
},
|
||||
{
|
||||
match: (h) => h === 'vimeo.com' || h === 'www.vimeo.com' || h === 'player.vimeo.com',
|
||||
renderer: 'vimeo',
|
||||
label: 'Vimeo',
|
||||
},
|
||||
{
|
||||
match: (h) => h === 'stream.mux.com' || h === 'mux.com' || h === 'www.mux.com',
|
||||
renderer: 'mux-video',
|
||||
label: 'Mux',
|
||||
},
|
||||
{
|
||||
match: (h) => h === 'open.spotify.com',
|
||||
renderer: 'spotify',
|
||||
label: 'Spotify',
|
||||
},
|
||||
{
|
||||
match: (h) =>
|
||||
h === 'watch.videodelivery.net' ||
|
||||
h === 'videodelivery.net' ||
|
||||
h === 'cloudflarestream.com' ||
|
||||
h === 'www.cloudflarestream.com',
|
||||
renderer: 'cloudflare',
|
||||
label: 'Cloudflare',
|
||||
},
|
||||
{
|
||||
match: (h) => h === 'cdn.jwplayer.com' || h === 'content.jwplatform.com',
|
||||
renderer: 'jwplayer',
|
||||
label: 'JW Player',
|
||||
},
|
||||
{
|
||||
match: (h) => h === 'fast.wistia.com' || h === 'fast.wistia.net' || h.endsWith('.wistia.com'),
|
||||
renderer: 'wistia',
|
||||
label: 'Wistia',
|
||||
},
|
||||
];
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov', '.ogv']);
|
||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.flac', '.aac']);
|
||||
|
||||
function parseUrl(input: string): URL | null {
|
||||
try {
|
||||
return new URL(input);
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${input}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getExtension(pathname: string): string {
|
||||
const clean = pathname.split('?')[0]!.split('#')[0]!;
|
||||
const dot = clean.lastIndexOf('.');
|
||||
if (dot === -1) return '';
|
||||
return clean.slice(dot).toLowerCase();
|
||||
}
|
||||
|
||||
function resolveRendererForUseCase(renderer: Renderer, useCase: UseCase): Renderer {
|
||||
if (renderer !== 'mux-video') return renderer;
|
||||
if (useCase === 'default-audio') return 'mux-audio';
|
||||
if (useCase === 'background-video') return 'mux-background-video';
|
||||
return 'mux-video';
|
||||
}
|
||||
|
||||
export function detectRenderer(url: string, useCase: UseCase): DetectionResult | null {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parsed = parseUrl(trimmed);
|
||||
if (!parsed) return null;
|
||||
|
||||
// Check domain rules first
|
||||
for (const rule of DOMAIN_RULES) {
|
||||
if (rule.match(parsed.hostname)) {
|
||||
const resolved = resolveRendererForUseCase(rule.renderer, useCase);
|
||||
if (!isRendererValidForUseCase(resolved, useCase)) return null;
|
||||
return { renderer: resolved, label: rule.label };
|
||||
}
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
const ext = getExtension(parsed.pathname);
|
||||
|
||||
if (ext === '.m3u8') {
|
||||
if (!isRendererValidForUseCase('hls', useCase)) return null;
|
||||
return { renderer: 'hls', label: 'HLS' };
|
||||
}
|
||||
|
||||
if (ext === '.mpd') {
|
||||
if (!isRendererValidForUseCase('dash', useCase)) return null;
|
||||
return { renderer: 'dash', label: 'DASH' };
|
||||
}
|
||||
|
||||
if (VIDEO_EXTENSIONS.has(ext)) {
|
||||
if (!isRendererValidForUseCase('html5-video', useCase)) return null;
|
||||
return { renderer: 'html5-video', label: 'HTML5 Video' };
|
||||
}
|
||||
|
||||
if (AUDIO_EXTENSIONS.has(ext)) {
|
||||
if (!isRendererValidForUseCase('html5-audio', useCase)) return null;
|
||||
return { renderer: 'html5-audio', label: 'HTML5 Audio' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractMuxPlaybackId(url: string): string | null {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed) return null;
|
||||
|
||||
if (parsed.hostname !== 'stream.mux.com') return null;
|
||||
|
||||
// pathname is /{PLAYBACK_ID} or /{PLAYBACK_ID}.m3u8
|
||||
const segment = parsed.pathname.slice(1); // remove leading /
|
||||
if (!segment) return null;
|
||||
|
||||
return segment.replace(/\.m3u8$/, '') || null;
|
||||
}
|
||||
|
||||
export function isRendererValidForUseCase(renderer: Renderer, useCase: UseCase): boolean {
|
||||
return VALID_RENDERERS[useCase].includes(renderer);
|
||||
}
|
||||
|
||||
const RENDERER_ARTICLES: Record<Renderer, 'a' | 'an'> = {
|
||||
'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',
|
||||
};
|
||||
|
||||
export function articleFor(renderer: Renderer): 'a' | 'an' {
|
||||
return RENDERER_ARTICLES[renderer];
|
||||
}
|
||||
Reference in New Issue
Block a user