diff --git a/apps/www/app/layout.config.tsx b/apps/www/app/layout.config.tsx index 80fc38fe..91830451 100644 --- a/apps/www/app/layout.config.tsx +++ b/apps/www/app/layout.config.tsx @@ -1,11 +1,10 @@ import type { BaseLayoutProps, LinkItemType } from "fumadocs-ui/layouts/shared" -import { - BlueprintIcon, - QuestionIcon, - SparkleIcon, -} from "@phosphor-icons/react/dist/ssr" -import { RssIcon } from "lucide-react" +import { IconBlocks } from "@central-icons-react/round-filled-radius-0-stroke-1/IconBlocks" +import { IconBook } from "@central-icons-react/round-filled-radius-0-stroke-1/IconBook" +import { IconBuildingBlocks } from "@central-icons-react/round-filled-radius-0-stroke-1/IconBuildingBlocks" +import { IconNewspaper2 } from "@central-icons-react/round-filled-radius-0-stroke-1/IconNewspaper2" +import { IconRocket } from "@central-icons-react/round-filled-radius-0-stroke-1/IconRocket" import Image from "next/image" import { Icons } from "@/components/icons" @@ -33,28 +32,34 @@ const COMMON_LINKS: LinkItemType[] = [ export const baseOptions: BaseLayoutProps = { links: [ { - icon: , + icon: , text: "Quick Start", type: "main", url: "/docs/quick-start", }, { - icon: , + icon: , text: "Introduction", type: "main", url: "/docs/introduction", }, { - icon: , - text: "Concepts", + icon: , + text: "Usage", type: "main", - url: "/docs/concepts", + url: "/docs/usage", }, { - icon: , - text: "Events", + icon: , + text: "Blocks", type: "main", - url: "/docs/events", + url: "/blocks/video-player", + }, + { + icon: , + text: "Concepts", + type: "main", + url: "/docs/concepts", }, ...COMMON_LINKS, ], diff --git a/apps/www/components/mdx-components.tsx b/apps/www/components/mdx-components.tsx index 8e36d5bf..a8a10415 100644 --- a/apps/www/components/mdx-components.tsx +++ b/apps/www/components/mdx-components.tsx @@ -8,6 +8,7 @@ import * as TabsComponents from "fumadocs-ui/components/tabs" import defaultComponents from "fumadocs-ui/mdx" import { ComponentPreview } from "@/components/component-preview" +import { Mermaid } from "@/components/mdx/mermaid" const generator = createGenerator() @@ -21,6 +22,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { Attribution, ComponentPreview, License, + Mermaid, pre: ({ ref: _ref, ...props }: React.ComponentPropsWithRef) => (
{props.children}
diff --git a/apps/www/components/mdx/mermaid.tsx b/apps/www/components/mdx/mermaid.tsx new file mode 100644 index 00000000..257b3f8b --- /dev/null +++ b/apps/www/components/mdx/mermaid.tsx @@ -0,0 +1,41 @@ +import { renderMermaidSVG } from "beautiful-mermaid" +import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock" + +export async function Mermaid({ chart }: { chart: string }) { + try { + const svg = renderMermaidSVG(chart, { + accent: "var(--color-fd-primary)", + bg: "transparent", + border: "color-mix(in oklab, var(--color-fd-border) 86%, transparent)", + fg: "var(--color-fd-foreground)", + font: "var(--font-sans), ui-sans-serif, system-ui, sans-serif", + interactive: true, + layerSpacing: 52, + line: "color-mix(in oklab, var(--color-fd-muted-foreground) 58%, transparent)", + muted: "var(--color-fd-muted-foreground)", + nodeSpacing: 36, + padding: 32, + surface: + "color-mix(in oklab, var(--color-fd-card) 92%, var(--color-fd-primary) 8%)", + transparent: true, + }) + + return ( +
+
+
+ ) + } catch { + return ( + +
{chart}
+
+ ) + } +} diff --git a/apps/www/components/players/audio-player/demo-player.tsx b/apps/www/components/players/audio-player/demo-player.tsx index b52d23d2..e00e1585 100644 --- a/apps/www/components/players/audio-player/demo-player.tsx +++ b/apps/www/components/players/audio-player/demo-player.tsx @@ -28,9 +28,8 @@ export function AudioPlayerDemo({ }: AudioPlayerDemoProps) { const [playlist, setPlaylist] = useState([]) const storeHydrated = useStreamPanelStoreHydrated() - const hasPersistedAudioSelection = useStreamPanelStore((s) => - Boolean(s.contentSelections.audio) - ) + const audioSelection = useStreamPanelStore((s) => s.contentSelections.audio) + const hasPersistedAudioSelection = Boolean(audioSelection) useEffect(() => { const abortController = new AbortController() @@ -54,7 +53,7 @@ export function AudioPlayerDemo({ return ( {children} diff --git a/apps/www/components/stream-panel/use-stream-panel-sync.ts b/apps/www/components/stream-panel/use-stream-panel-sync.ts index d49ac601..d13c4794 100644 --- a/apps/www/components/stream-panel/use-stream-panel-sync.ts +++ b/apps/www/components/stream-panel/use-stream-panel-sync.ts @@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useRef } from "react" -import type { Asset, UseAssetOptions } from "@/registry/default/hooks/use-asset" +import type { + Asset, + AssetEvents, + UseAssetOptions, +} from "@/registry/default/hooks/use-asset" import type { PlaybackStore } from "@/registry/default/hooks/use-playback" import { @@ -22,12 +26,18 @@ import { useStreamPanelStoreHydrated, } from "@/components/stream-panel/use-stream-panel" import { getPresetsForType, type StreamPreset } from "@/lib/stream-presets" -import { useAsset } from "@/registry/default/hooks/use-asset" +import { + AssetRecoveryAction, + useAsset, +} from "@/registry/default/hooks/use-asset" import { useMediaStore } from "@/registry/default/hooks/use-media" import { PLAYBACK_FEATURE_KEY } from "@/registry/default/hooks/use-playback" import { usePlayerStore } from "@/registry/default/hooks/use-player" import { useVolumeStore } from "@/registry/default/hooks/use-volume" -import { useMediaFeatureApi } from "@/registry/default/ui/media-provider" +import { + useMediaEvents, + useMediaFeatureApi, +} from "@/registry/default/ui/media-provider" const DEFAULT_VIDEO_PRESET_ID = "mux-big-buck-bunny" @@ -37,6 +47,7 @@ export function useStreamPanelSync({ playerType?: StreamPanelPlayerType } = {}) { const playbackApi = useMediaFeatureApi(PLAYBACK_FEATURE_KEY) + const events = useMediaEvents() const mediaElement = useMediaStore((state) => state.mediaElement) const player = usePlayerStore((state) => state.instance) @@ -78,6 +89,7 @@ export function useStreamPanelSync({ context.signal, blenderStreamCache ) + // DEV: The selected asset may change while the Blender stream URL is resolving. context.signal.throwIfAborted() await context.loadDefault( @@ -86,6 +98,8 @@ export function useStreamPanelSync({ src: stream.playback.hls, } ) + // DEV: loadDefault is async; avoid adding captions to a superseded player load. + context.signal.throwIfAborted() try { await addBlenderCaptions(context.player, stream) @@ -112,26 +126,31 @@ export function useStreamPanelSync({ }) }, }, - onAssetChange: (event) => { - const selection = - useStreamPanelStore.getState().contentSelections[playerType] - if (!selection || selection.kind !== "playlist") return - if (selection.index === event.currentIndex) return - - setContentSelection(playerType, { - ...selection, - index: event.currentIndex, - }) - }, - onLoadError: (_asset, error) => { - playbackApi.getState().playback.setError(error) - return "stop" + recover: { + loadError: (_asset, error) => { + playbackApi.getState().playback.setError(error) + return AssetRecoveryAction.Stop + }, }, }), - [blenderStreamCache, playbackApi, playerType, setContentSelection] + [blenderStreamCache, playbackApi] ) - const { loadPlaylist } = useAsset(assetOptions) + const { loadSource } = useAsset() + + useEffect(() => { + return events.on("assetchange", (event) => { + const selection = + useStreamPanelStore.getState().contentSelections[playerType] + if (!selection || selection.kind !== "playlist") return + if (selection.index === event.currentIndex) return + + setContentSelection(playerType, { + ...selection, + index: event.currentIndex, + }) + }) + }, [events, playerType, setContentSelection]) useEffect(() => { if (!mediaElement) return @@ -180,10 +199,10 @@ export function useStreamPanelSync({ src, type: playerType, } - loadPlaylist([asset as unknown as Asset]) + loadSource(asset as unknown as Asset, { loading: assetOptions }) return asset }, - [loadPlaylist, playbackApi, playerType] + [assetOptions, loadSource, playbackApi, playerType] ) const loadPlaylistPreset = useCallback( @@ -202,7 +221,10 @@ export function useStreamPanelSync({ index, kind: "playlist", }) - loadPlaylist(assets, index) + loadSource(assets, { + initialIndex: index, + loading: assetOptions, + }) }) .catch((error: unknown) => { if (error instanceof DOMException && error.name === "AbortError") @@ -211,7 +233,7 @@ export function useStreamPanelSync({ playbackApi.getState().playback.setError(error) }) }, - [loadPlaylist, playbackApi, playerType, setContentSelection] + [assetOptions, loadSource, playbackApi, playerType, setContentSelection] ) const restoreContentSelection = useCallback( @@ -226,7 +248,7 @@ export function useStreamPanelSync({ ) if (preset) { abortPlaylistRequest() - loadPlaylist([preset as unknown as Asset]) + loadSource(preset as unknown as Asset, { loading: assetOptions }) return } @@ -237,7 +259,8 @@ export function useStreamPanelSync({ [ abortPlaylistRequest, loadCustomStream, - loadPlaylist, + assetOptions, + loadSource, loadPlaylistPreset, playerType, ] @@ -277,9 +300,15 @@ export function useStreamPanelSync({ (preset: StreamPreset, kind: StreamPanelContentKind = "stream") => { abortPlaylistRequest() setContentSelection(playerType, { id: preset.id, index: 0, kind }) - loadPlaylist([preset as unknown as Asset]) + loadSource(preset as unknown as Asset, { loading: assetOptions }) }, - [abortPlaylistRequest, loadPlaylist, playerType, setContentSelection] + [ + abortPlaylistRequest, + assetOptions, + loadSource, + playerType, + setContentSelection, + ] ) const handlePlaylistPresetChange = useCallback( diff --git a/apps/www/content/docs/blocks/audio-player.mdx b/apps/www/content/docs/blocks/audio-player.mdx index cf266382..245f1675 100644 --- a/apps/www/content/docs/blocks/audio-player.mdx +++ b/apps/www/content/docs/blocks/audio-player.mdx @@ -13,7 +13,7 @@ status: free npx shadcn add @limeplay/audio-player ``` -## Usage +## Minimal Usage ```tsx import { @@ -32,10 +32,53 @@ const playlist: AudioPlayerAsset[] = [ ] export function Player() { - return + return } ``` +Use `source` for media loading. Use `mediaProps` only for native audio attributes. + +```tsx + +``` + +## Single Track Usage + +Pass one asset object when you do not need a queue. + +```tsx + +``` + +## Custom Loading + +The audio block includes an opinionated default resolver for `src` and `playbackUrls.primary`. Use `loading.resolveSource` when your app needs signed URLs, token refresh, or a different URL shape. + +```tsx + { + const response = await fetch(`/api/tracks/${asset.id}/source`, { signal }) + const source = await response.json() + + return source.url + }, + }} +/> +``` + ## Features - Playlist queue with track artwork, title, genre, and duration. @@ -43,12 +86,14 @@ export function Player() { - Previous, play/pause, next, volume, mute, repeat, and shuffle controls. - Playback URL resolution for direct `src` values or `playbackUrls.primary` endpoints. - Automatic skip/reload behavior for recoverable load and playback failures. +- Shared `source` and `loading` contract used by all source-driven blocks. ## Notes - `AudioPlayer` does not ship with bundled tracks. Pass a playlist from your app. - Use `duration` in milliseconds when you want stable duration labels before metadata loads. -- Use `resolveSource` for signed audio URLs, token refresh, or custom playback URL APIs. +- Do not pass `mediaProps.src`; pass `source` instead. +- For the full block loading model, see [Usage](/docs/usage). ## API Reference diff --git a/apps/www/content/docs/blocks/meta.json b/apps/www/content/docs/blocks/meta.json index 4be6b723..67f56810 100644 --- a/apps/www/content/docs/blocks/meta.json +++ b/apps/www/content/docs/blocks/meta.json @@ -1,5 +1,6 @@ { "title": "Blocks", - "description": "Headless showcase pages for registry blocks.", + "description": "Ready-to-use player blocks with shared source loading APIs.", + "pages": ["video-player", "audio-player"], "defaultOpen": true } diff --git a/apps/www/content/docs/blocks/video-player.mdx b/apps/www/content/docs/blocks/video-player.mdx index 4ab680e7..d9606258 100644 --- a/apps/www/content/docs/blocks/video-player.mdx +++ b/apps/www/content/docs/blocks/video-player.mdx @@ -13,7 +13,7 @@ status: free npx shadcn add @limeplay/video-player ``` -## Usage +## Minimal Usage ```tsx import { VideoPlayer } from "@/components/limeplay/video-player/components/media-player" @@ -22,10 +22,70 @@ const src = "https://ad391cc0d55b44c6a86d232548adc225.mediatailor.us-east-1.amazonaws.com/v1/master/d02fedbbc5a68596164208dd24e9b48aa60dadc7/singssai/master.m3u8" export function Player() { - return + return } ``` +Use `source` for media loading. Use `mediaProps` only for native video attributes. + +```tsx + +``` + +## Playlist Usage + +Pass an array to `source` when the player should manage a queue. + +```tsx +import { + VideoPlayer, + type VideoPlayerAsset, +} from "@/components/limeplay/video-player/components/media-player" + +const playlist: VideoPlayerAsset[] = [ + { + id: "episode-1", + poster: "/posters/episode-1.jpg", + src: "https://example.com/episode-1.mpd", + title: "Episode 1", + }, + { + id: "episode-2", + poster: "/posters/episode-2.jpg", + src: "https://example.com/episode-2.mpd", + title: "Episode 2", + }, +] + +export function Player() { + return +} +``` + +## Custom Loading + +Use `loading.resolveSource` when your app needs signed URLs, token refresh, or source lookup before Shaka loads the asset. + +```tsx + { + const response = await fetch(`/api/assets/${asset.id}/source`, { signal }) + const source = await response.json() + + return { + config: source.config, + src: source.url, + } + }, + }} +/> +``` + ## Features - Single-source and playlist-backed loading APIs. @@ -33,12 +93,14 @@ export function Player() { - Timeline scrubbing with buffered progress feedback. - Volume, mute, captions, playback rate, and picture-in-picture controls. - Shaka-backed playback for HLS, DASH, and DRM-capable streams. +- Shared `source` and `loading` contract used by all source-driven blocks. ## Notes - `VideoPlayer` renders a video element internally. You do not need an `as` prop. - Pass `mediaProps` for native video options like `muted`, `playsInline`, or `autoPlay`. -- Use `resolveSource` when your app needs signed URLs, token refresh, or custom asset loading. +- Do not pass `mediaProps.src`; pass `source` instead. +- For the full block loading model, see [Usage](/docs/usage). ## API Reference diff --git a/apps/www/content/docs/components/media-provider.mdx b/apps/www/content/docs/components/media-provider.mdx index 3d6b86a9..d2663e87 100644 --- a/apps/www/content/docs/components/media-provider.mdx +++ b/apps/www/content/docs/components/media-provider.mdx @@ -117,7 +117,7 @@ function Logger() { } ``` -See [Events](/docs/events) for the full event reference. +See [Concepts](/docs/concepts#event-and-action-bridge) for the event model. ## Multi-Instance diff --git a/apps/www/content/docs/concepts.mdx b/apps/www/content/docs/concepts.mdx index 16663808..4670d548 100644 --- a/apps/www/content/docs/concepts.mdx +++ b/apps/www/content/docs/concepts.mdx @@ -5,15 +5,17 @@ index: true The key ideas behind Limeplay's architecture. -## Features +## Feature Model -Every capability — playback, volume, timeline, captions — is a **feature**. A feature is a self-contained unit that provides: +Limeplay is built from **features**. A feature owns one capability, such as playback, volume, timeline, playlist, player setup, or asset loading. -- **Store slice** — A namespaced piece of state (e.g. `volume.level`, `playback.paused`) -- **Setup component** — An internal component that attaches DOM event listeners and syncs state (auto-mounted by `MediaProvider`) -- **Events** — Typed events emitted when state changes (e.g. `volumechange`, `seek`) +Each feature can provide: -You compose features with `createMediaKit`: +- A namespaced store slice, such as `playback.paused` or `volume.level`. +- A setup component that syncs native media or Shaka Player state. +- Typed events for state changes, such as `play`, `seek`, or `assetloaded`. + +You compose the features you need with `createMediaKit`. ```tsx title="lib/media.ts" "use client" @@ -26,97 +28,94 @@ import { volumeFeature } from "@/hooks/limeplay/use-volume" export const media = createMediaKit({ features: [mediaFeature(), playbackFeature(), volumeFeature()] as const, }) -``` - -This returns a `MediaProvider` component and typed hooks. Each feature's Setup component is auto-mounted inside `MediaProvider` — no manual wiring required. -### Available Features +export const { MediaProvider, useMediaApi, useMediaEvents, useMediaStore } = + media +``` -| Feature | Key | Factory | Events | -| -------------------------------------------------------- | ------------------ | --------------------------- | ---------------------------------------------------------------- | -| [Media](/docs/concepts) | `media` | `mediaFeature()` | — | -| [Player](/docs/hooks/use-player) | `player` | `playerFeature()` | `playerready`, `playererror`, `playbackerror`, `bufferingchange` | -| [Playback](/docs/hooks/use-playback) | `playback` | `playbackFeature()` | `play`, `pause`, `ended`, `buffering`, `statuschange` | -| [Volume](/docs/hooks/use-volume) | `volume` | `volumeFeature()` | `volumechange`, `mute` | -| [Timeline](/docs/hooks/use-timeline) | `timeline` | `timelineFeature()` | `timeupdate`, `durationchange`, `seek` | -| [Playlist](/docs/hooks/use-playlist) | `playlist` | `playlistFeature()` | `playlistchange` | -| [Playback Rate](/docs/hooks/use-playback-rate) | `playbackRate` | `playbackRateFeature()` | `ratechange` | -| [Picture-in-Picture](/docs/hooks/use-picture-in-picture) | `pictureInPicture` | `pictureInPictureFeature()` | `enterpictureinpicture`, `leavepictureinpicture` | -| [Captions](/docs/hooks/use-captions) | `captions` | `captionsFeature()` | — | -| [Asset](/docs/hooks/use-asset) | `asset` | `assetFeature()` | — | +Use this page to understand the model. Use the [Hooks](/docs/hooks) section when you need exact APIs. ## Store -State is managed by [Zustand](https://zustand.docs.pmnd.rs/) with [Immer](https://immerjs.github.io/immer/) middleware. Each `MediaProvider` creates an isolated store instance via React Context, so multiple players on the same page maintain independent state. - -### Reading State +Each `MediaProvider` creates an isolated Zustand store. Multiple players on the same page do not share state unless you explicitly wire them together. -Use the typed selector hooks to subscribe to specific fields: +Read state with selectors so components only re-render for the fields they use. ```tsx -// Per-feature convenience hook -const level = useVolumeStore((s) => s.level) -const paused = usePlaybackStore((s) => s.paused) - -// Or the unified hook from createMediaKit -const level = media.useMediaStore((s) => s.volume.level) +const paused = usePlaybackStore((state) => state.paused) +const volume = useVolumeStore((state) => state.level) ``` -Components only re-render when the selected value changes. - -### Mutating State - -For imperative mutations (effects, callbacks), use the store API: +For imperative code, use the media API returned by your kit. ```tsx -const api = media.useMediaApi() - -// Read without subscribing -api.getState().volume.level +const api = useMediaApi() -// Mutate with Immer draft api.setState(({ volume }) => { volume.level = 0.5 }) ``` -## Event & Action Bridge - -Limeplay separates concerns into two bridges: - -### Event Bridge +## Event And Action Bridge -Each feature's Setup component attaches native DOM event listeners to the `