-
+
{currentItem?.properties.title}
@@ -60,7 +70,17 @@ export function PlaylistController({ assets }: { assets: VideoAsset[] }) {
}
```
-Use `loadAsset` for a single item and `loadPlaylist` for a queue. When a playlist advances, `useAsset` loads the active item and can preload the next item.
+Use `loadSource` for the public source contract: a URL string, one asset, or an asset array. Use `loadAsset` and `loadPlaylist` when you are building lower-level controls that already know which asset or queue should be loaded.
+
+`useAsset()` does not accept options. Loading configuration is session-local and belongs to `loadSource`, `loadPlaylist`, or the `loading` prop on a block. The latest explicit load session wins.
+
+`sourceType` reports the active loading mode as `"asset"`, `"playlist"`, or `null` before a source has been selected. Use it to render mode-specific controls:
+
+```tsx
+const { sourceType } = useAsset()
+
+const showPlaylistControls = sourceType === "playlist"
+```
### Asset Interface
@@ -80,20 +100,75 @@ If `id` is omitted, `useAsset` derives one from `getAssetId` or `src`. Assets wi
Use `resolveSource` when the playable source is not stored directly on `asset.src`.
```tsx
-useAsset({
- resolveSource: async ({ asset, operation, signal }) => {
- const response = await fetch(`/api/assets/${asset.id}/source`, { signal })
- const source = await response.json()
-
- return {
- config: source.config,
- src: source.url,
- }
+const { loadSource } = useAsset()
+
+loadSource(
+ { id: "movie-123", title: "Movie" },
+ {
+ loading: {
+ resolveSource: async ({ asset, signal }) => {
+ const response = await fetch(`/api/assets/${asset.id}/source`, {
+ signal,
+ })
+ const source = await response.json()
+
+ return {
+ config: source.config,
+ src: source.url,
+ }
+ },
+ },
+ }
+)
+```
+
+`resolveSource` is used by the default load and preload paths. Use `loader.load` or `loader.preload` when loading and preloading need different behavior.
+
+### Recovery
+
+Use `recover.loadError` and `recover.playbackError` to decide how Limeplay should recover from failures.
+
+```tsx
+import { AssetRecoveryAction, useAsset } from "@/hooks/limeplay/use-asset"
+
+const { loadSource } = useAsset()
+
+loadSource(playlist, {
+ loading: {
+ maxRetries: 2,
+ recover: {
+ loadError: (_asset, _error, { hasNext, retryCount }) => {
+ if (retryCount < 2) return AssetRecoveryAction.Retry
+ return hasNext ? AssetRecoveryAction.Skip : AssetRecoveryAction.Stop
+ },
+ playbackError: async (_asset, _error, { currentTime }) => ({
+ action: AssetRecoveryAction.Reload,
+ startTime: currentTime,
+ }),
+ },
},
})
```
-`operation` is `"load"` or `"preload"`, so source endpoints can avoid expensive work during preloading when needed.
+Lifecycle notifications are media events, not `UseAssetOptions` callbacks.
+
+```tsx
+import { useEffect } from "react"
+
+import { useMediaEvents } from "@/components/limeplay/media-provider"
+
+export function AssetEventsLogger() {
+ const events = useMediaEvents()
+
+ useEffect(() => {
+ return events.on("assetloaderror", ({ asset, error }) => {
+ console.error("Asset failed", asset.id, error)
+ })
+ }, [events])
+
+ return null
+}
+```
### Options
@@ -111,7 +186,7 @@ useAsset({
## Store
-`assetFeature` adds a store slice that tracks active options, load cancellation, retry state, and preload cancellation.
+`assetFeature` adds a store slice that tracks the active load session, source type, load cancellation, retry state, and preload cancellation.
### State
@@ -129,11 +204,11 @@ useAsset({
## Events
-`useAsset` listens to player and playlist events instead of exposing a separate event emitter.
+`useAsset` emits asset lifecycle events through the shared media event emitter.
-`useAsset` orchestrates `usePlayer` and `usePlaylist`. It owns load cancellation, playlist-driven loading, preloading, auto-advance on playback end, and error recovery.
+`useAsset` orchestrates `usePlayer` and `usePlaylist`. It owns load cancellation, playlist-driven loading, preloading, auto-advance on playback end, and error recovery. Queue mutation methods still live on [`usePlaylist`](/docs/hooks/use-playlist).
diff --git a/apps/www/content/docs/hooks/use-playback-source.mdx b/apps/www/content/docs/hooks/use-playback-source.mdx
index 6df75bd6..3838cf4f 100644
--- a/apps/www/content/docs/hooks/use-playback-source.mdx
+++ b/apps/www/content/docs/hooks/use-playback-source.mdx
@@ -16,17 +16,17 @@ npx shadcn add @limeplay/use-playback-source
## Usage
-Use `PlaybackSourceController` inside a block to load exactly one source model: `asset`, `playlist`, or `mediaSrc`.
+Use `PlaybackSourceController` inside a block to normalize the block's `source` prop and pass it into `useAsset().loadSource(...)` after the Shaka player is ready.
```tsx
import { PlaybackSourceController } from "@/hooks/limeplay/use-playback-source"
export function SourceController({ src }: { src?: string }) {
- return
+ return
}
```
-For typed assets, pass `asset`, `playlist`, `getAssetId`, and `resolveSource` through to `useAsset`.
+For typed assets, pass one asset or an asset array to `source`, then pass source resolution and recovery policies through `loading`.
```tsx
import type { Asset } from "@/hooks/limeplay/use-asset"
@@ -40,17 +40,28 @@ interface VideoAsset extends Asset {
export function VideoSource({ playlist }: { playlist: VideoAsset[] }) {
return (
asset.slug}
- playlist={playlist}
- resolveSource={({ asset }) => asset.playbackUrl}
+ source={playlist}
+ loading={{
+ getAssetId: (asset) => asset.slug,
+ resolveSource: ({ asset }) => asset.playbackUrl,
+ }}
/>
)
}
```
+Use `sourceKey` when the source is recreated often but represents the same logical media session.
+
+```tsx
+ item.slug).join("|")}
+/>
+```
+
## Feature Registration
-`use-playback-source` is a controller helper, not a store feature. Register `assetFeature` with your media kit, then mount `PlaybackSourceController` inside the player tree.
+`use-playback-source` is an opinionated controller helper, not a store feature. Register `assetFeature` with your media kit, then mount `PlaybackSourceController` inside the player tree.
```tsx title="lib/media.ts"
"use client"
@@ -72,10 +83,11 @@ export const media = createMediaKit({
```
```tsx
+import type { Asset } from "@/hooks/limeplay/use-asset"
import type { UsePlaybackSourceOptions } from "@/hooks/limeplay/use-playback-source"
import { PlaybackSourceController } from "@/hooks/limeplay/use-playback-source"
-export function Source(
+export function Source(
props: UsePlaybackSourceOptions
) {
return
@@ -86,7 +98,7 @@ export function Source(
| State | Owner | Description |
| ----- | ----- | ----------- |
-| `asset`, `playlist`, `mediaSrc` | `UsePlaybackSourceOptions` | Source inputs normalized by `PlaybackSourceController`. |
+| `source` | `UsePlaybackSourceOptions` | Source string, one asset, or asset array normalized by `PlaybackSourceController`. |
| `sourceKey` | `UsePlaybackSourceOptions` | Optional stable key used to avoid duplicate playlist loads. |
| `player` | `use-player` | The current Shaka player instance required before loading. |
@@ -95,15 +107,15 @@ export function Source(
| Action | Symbol | Description |
| ------ | ------ | ----------- |
| Load source | `PlaybackSourceController` | Mounts a controller that calls `usePlaybackSource`. |
-| Normalize options | `usePlaybackSource` | Converts one asset, a playlist, or `mediaSrc` into a playlist load. |
-| Load playlist | `loadPlaylist` from `useAsset` | Receives normalized assets and the configured `initialIndex`. |
+| Normalize options | `usePlaybackSource` | Converts a source string, one asset, or an asset array into a load session. |
+| Load source | `loadSource` from `useAsset` | Receives the source plus `loading`, `initialIndex`, `sourceKey`, and `sourceType`. |
## Events
| Event | Emitted by | Description |
| ----- | ---------- | ----------- |
| `playlistchange` | `use-playlist` | Fired after the normalized source is loaded into the queue and the active item changes. |
-| `playerready` | `use-player` | The controller waits for a player instance before calling `loadPlaylist`. |
+| `playerready` | `use-player` | The controller waits for a player instance before calling `loadSource`. |
| None | `use-playback-source` | The hook itself does not emit custom events. |
## API Reference
diff --git a/apps/www/content/docs/hooks/use-playback.mdx b/apps/www/content/docs/hooks/use-playback.mdx
index 241fc863..eb4e777f 100644
--- a/apps/www/content/docs/hooks/use-playback.mdx
+++ b/apps/www/content/docs/hooks/use-playback.mdx
@@ -60,7 +60,7 @@ const togglePaused = usePlaybackStore((s) => s.togglePaused)
## Events
-Emits to the [event system](/docs/events):
+Emits to the [event system](/docs/concepts#event-and-action-bridge):
| Event | Payload | When |
| -------------- | ------------------------ | ----------------------- |
diff --git a/apps/www/content/docs/hooks/use-player.mdx b/apps/www/content/docs/hooks/use-player.mdx
index 82b2b7f1..90dacb99 100644
--- a/apps/www/content/docs/hooks/use-player.mdx
+++ b/apps/www/content/docs/hooks/use-player.mdx
@@ -1,6 +1,6 @@
---
title: use-player
-description: Feature for Shaka Player initialization, asset loading, and preloading.
+description: Low-level feature for Shaka Player initialization and manual load/preload control.
---
## Installation
@@ -42,7 +42,7 @@ const containerRef = usePlayerStore((s) => s.containerRef)
## usePlayer Hook
-The `usePlayer` hook provides methods to load and preload assets:
+The `usePlayer` hook provides low-level methods to load and preload assets. Prefer [`useAsset`](/docs/hooks/use-asset) or block `source` props for most application code because they include playlist state, cancellation, preloading lifecycle, and recovery policies.
```tsx
import { usePlayer } from "@/hooks/limeplay/use-player"
@@ -78,9 +78,9 @@ const { load, preload, player } = usePlayer({
| Event | Payload | When |
| ----------------- | ----------------- | ------------------------ |
-| `playerready` | `{ player }` | Shaka Player initialized |
+| `playerready` | `void` | Shaka Player initialized |
| `playererror` | `{ error }` | Player-level error |
-| `playbackerror` | `Error` | Playback error |
+| `playbackerror` | `{ error, currentTime }` | Playback error |
| `bufferingchange` | `{ isBuffering }` | Buffering state changes |
### Debug Mode
diff --git a/apps/www/content/docs/quick-start.mdx b/apps/www/content/docs/quick-start.mdx
index 3f4b31cf..6dabe69e 100644
--- a/apps/www/content/docs/quick-start.mdx
+++ b/apps/www/content/docs/quick-start.mdx
@@ -23,7 +23,7 @@ The fastest path — install a complete, ready-to-use player:
npx shadcn add @limeplay/video-player
```
-This gives you a full-featured video player with playback controls, volume, timeline, captions, and more. Customize it from your `components/` directory.
+This gives you a full-featured video player with playback controls, volume, timeline, captions, and more. Customize it from your `components/` directory. For more details, see the [video player documentation](/blocks/video-player).
## Option B: Build from Scratch
@@ -35,7 +35,7 @@ This gives you a full-featured video player with playback controls, volume, time
Install the core provider and the features you need:
```npm
-npx shadcn add @limeplay/media-provider @limeplay/use-playback @limeplay/use-volume @limeplay/use-timeline @limeplay/use-player
+npx shadcn add @limeplay/media-provider @limeplay/use-player @limeplay/use-playback @limeplay/use-volume @limeplay/use-timeline @limeplay/use-playlist @limeplay/use-asset @limeplay/use-playback-source
```
Create a `lib/media.ts` file that composes your features:
@@ -44,8 +44,10 @@ Create a `lib/media.ts` file that composes your features:
"use client"
import { mediaFeature } from "@/hooks/limeplay/use-media"
+import { assetFeature } from "@/hooks/limeplay/use-asset"
import { playerFeature } from "@/hooks/limeplay/use-player"
import { playbackFeature } from "@/hooks/limeplay/use-playback"
+import { playlistFeature } from "@/hooks/limeplay/use-playlist"
import { volumeFeature } from "@/hooks/limeplay/use-volume"
import { timelineFeature } from "@/hooks/limeplay/use-timeline"
import { createMediaKit } from "@/components/limeplay/media-provider"
@@ -55,6 +57,8 @@ export const media = createMediaKit({
mediaFeature(),
playerFeature(),
playbackFeature(),
+ playlistFeature(),
+ assetFeature(),
volumeFeature(),
timelineFeature(),
] as const,
@@ -87,10 +91,12 @@ import { PlaybackControl } from "@/components/limeplay/playback-control"
import * as Volume from "@/components/limeplay/volume-control"
import * as Timeline from "@/components/limeplay/timeline-control"
import * as Layout from "@/components/limeplay/player-layout"
+import { PlaybackSourceController } from "@/hooks/limeplay/use-playback-source"
-export function MediaPlayer() {
+export function MediaPlayer({ src }: { src: string }) {
return (
+
@@ -115,32 +121,9 @@ export function MediaPlayer() {
-### Load content
-
-Pass a source to your player. The simplest approach is loading via [`usePlayer`](/docs/hooks/use-player) inside your `MediaPlayer`:
-
-```tsx title="components/media-player.tsx"
-"use client"
-
-import { usePlayer } from "@/hooks/limeplay/use-player"
-import { useMediaEvents } from "@/lib/media"
-// ... other imports from Step 3
-
-export function MediaPlayer({ src }: { src: string }) {
- const { player } = usePlayer()
- const events = useMediaEvents()
-
- React.useEffect(() => {
- return events.on("playerready", ({ player }) => {
- player.load(src)
- })
- }, [events, src])
-
- return {/* ... same layout as Step 3 */}
-}
-```
+### Render with content
-Then render it with a source:
+Render your player with a source. `PlaybackSourceController` passes this source through [`useAsset`](/docs/hooks/use-asset), so the same pattern works for URLs, asset objects, and playlists.
```tsx title="app/page.tsx"
import { MediaPlayer } from "@/components/media-player"
@@ -152,7 +135,7 @@ export default function Page() {
}
```
-For playlist management, error handling, and preloading, see [`useAsset`](/docs/hooks/use-asset).
+For blocks, custom loading, playlists, error recovery, and preloading, see [Usage](/docs/usage).
diff --git a/apps/www/content/docs/usage.mdx b/apps/www/content/docs/usage.mdx
new file mode 100644
index 00000000..8c10e289
--- /dev/null
+++ b/apps/www/content/docs/usage.mdx
@@ -0,0 +1,292 @@
+---
+title: Usage
+description: How to use Limeplay blocks, source loading, and asset orchestration.
+---
+
+Limeplay blocks are ready-to-use players built on the same asset loading contract. Pass playable content through `source`, pass native media attributes through `mediaProps`, and customize loading behavior through `loading`.
+
+## Start with a Block
+
+Install a block and render it with a source.
+
+```npm
+npx shadcn add @limeplay/video-player
+```
+
+```tsx title="components/player.tsx"
+import { VideoPlayer } from "@/components/limeplay/video-player/components/media-player"
+
+export function Player() {
+ return (
+
+ )
+}
+```
+
+For playlist-style blocks, pass an array of assets to the same `source` prop.
+
+```tsx title="components/audio-player.tsx"
+import {
+ AudioPlayer,
+ type AudioPlayerAsset,
+} from "@/components/limeplay/audio-player/components/media-player"
+
+const tracks: AudioPlayerAsset[] = [
+ {
+ id: "soundhelix-1",
+ poster: "https://placehold.co/160x160/png",
+ src: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
+ title: "SoundHelix Song 1",
+ },
+]
+
+export function Player() {
+ return
+}
+```
+
+Do not pass `src` through `mediaProps`. Blocks reserve the native media element for Limeplay and Shaka Player. Use `source` for media loading and `mediaProps` for native options such as `autoPlay`, `muted`, `loop`, `controls`, `playsInline`, and `className`.
+
+## Block Props
+
+Every source-driven block should expose the same loading props.
+
+| Prop | Purpose |
+| --- | --- |
+| `source` | A source string, one asset object, or an array of asset objects. |
+| `loading` | Source resolution, custom load/preload handlers, recovery policies, and asset ID rules. |
+| `sourceKey` | Stable key for dynamic sources when object identity changes between renders. |
+| `autoLoad` | Whether the block loads `source` automatically after the Shaka player is ready. Defaults to `true`. |
+| `initialIndex` | Initial item index when `source` is a playlist. |
+| `mediaProps` | Native media attributes except `src` and `as`. |
+| `children` | Optional custom UI rendered inside or alongside the block, depending on the block. |
+
+## Opinionated and Unopinionated APIs
+
+Limeplay has two source-loading layers.
+
+| Layer | Use it when | What it does |
+| --- | --- | --- |
+| Opinionated blocks and `usePlaybackSource` | You are building reusable player blocks. | Normalizes `source`, waits for the player, deduplicates loads with `sourceKey`, and calls `useAsset().loadSource(...)`. |
+| Unopinionated `useAsset` | You need app-specific orchestration. | Gives direct access to `loadSource`, `loadAsset`, `loadPlaylist`, `preloadAsset`, queue read state, and recovery behavior. |
+
+Blocks should usually wrap `PlaybackSourceController` from `use-playback-source`. App code that needs imperative control can call `useAsset` directly inside the same `MediaProvider` tree.
+
+```tsx title="components/source-controller.tsx"
+import { PlaybackSourceController } from "@/hooks/limeplay/use-playback-source"
+
+export function SourceController({ src }: { src: string }) {
+ return
+}
+```
+
+```tsx title="components/custom-actions.tsx"
+import { useAsset } from "@/hooks/limeplay/use-asset"
+
+export function CustomActions() {
+ const { currentItem, loadSource, preloadNext } = useAsset()
+
+ return (
+
+
+
+ {currentItem?.properties.title}
+
+ )
+}
+```
+
+Use `usePlayer` directly only when you want to bypass asset, playlist, preload, and recovery orchestration and call Shaka Player yourself.
+
+## Source Shapes
+
+`source` accepts three shapes.
+
+```tsx
+
+```
+
+```tsx
+
+```
+
+```tsx
+
+```
+
+Assets can omit `src` when `loading.resolveSource` or `loading.loader.load` provides the playable URL.
+
+## Default Loaders
+
+The default loader covers the common path for HLS, DASH, progressive media, Shaka config, playlists, and recovery.
+
+| Capability | Default behavior |
+| --- | --- |
+| Single URL | A string source becomes `{ src }` and is loaded with `player.load(src)`. |
+| Asset URL | `asset.src` is loaded with Shaka Player. |
+| Shaka config | `asset.config` and resolved source config are applied before loading. |
+| Source resolution | `loading.resolveSource` can return a URL or `{ src, config }`. |
+| Preloaded source | If a preload manager exists for the asset ID, loading consumes it. |
+| Playlist | Arrays are normalized into the playlist queue and the active item is loaded. |
+| Asset IDs | IDs come from `asset.id`, then `loading.getAssetId`, then a hash of `asset.src`. |
+| Cancellation | Starting a new load aborts the previous load session. |
+| Load errors | By default, Limeplay skips to the next playlist item when one exists; otherwise it sets playback error state. |
+| Playback errors | By default, Limeplay reloads the current asset from the current playback time. |
+| Ended | Limeplay advances to the next playlist item when one exists. |
+
+Use `loading` when the default loader needs app-specific behavior.
+
+```tsx
+ {
+ const response = await fetch(`/api/assets/${asset.id}/source`, { signal })
+ const source = await response.json()
+
+ return {
+ config: source.config,
+ src: source.url,
+ }
+ },
+ }}
+/>
+```
+
+Use `loading.loader` only when you need to fully control Shaka loading or preloading. Call `loadDefault` or `preloadDefault` to reuse Limeplay's built-in behavior after your custom work.
+
+```tsx
+ {
+ if (signal.aborted) return
+
+ player.configure({
+ drm: {
+ servers: {
+ "com.widevine.alpha": "/api/license/widevine",
+ },
+ },
+ })
+
+ await loadDefault(asset.src)
+ },
+ },
+ }}
+/>
+```
+
+## Recovery Policies
+
+Recovery policies decide what Limeplay should do after failures. Notifications belong to media events, not `loading` callbacks.
+
+```tsx
+import { AssetRecoveryAction } from "@/hooks/limeplay/use-asset"
+
+ {
+ if (retryCount < 2) return AssetRecoveryAction.Retry
+ return hasNext ? AssetRecoveryAction.Skip : AssetRecoveryAction.Stop
+ },
+ playbackError: async (_asset, _error, { currentTime }) => ({
+ action: AssetRecoveryAction.Reload,
+ startTime: currentTime,
+ }),
+ },
+ }}
+/>
+```
+
+Subscribe to asset lifecycle events with `useMediaEvents` from your media kit.
+
+```tsx
+import { useEffect } from "react"
+
+import { useMediaEvents } from "@/components/limeplay/video-player/lib/media-kit"
+
+export function AssetLogger() {
+ const events = useMediaEvents()
+
+ useEffect(() => {
+ return events.on("assetloaded", ({ asset }) => {
+ console.info("Loaded", asset.title ?? asset.id)
+ })
+ }, [events])
+
+ return null
+}
+```
+
+## Building New Blocks
+
+When you build a new block, keep the public loading API consistent with the existing blocks.
+
+```tsx title="components/custom-video-player.tsx"
+"use client"
+
+import type React from "react"
+import type { Asset, PlayerSource, UseAssetOptions } from "@/hooks/limeplay/use-asset"
+
+import { MediaProvider } from "@/components/limeplay/custom-video-player/lib/media-kit"
+import { PlaybackSourceController } from "@/hooks/limeplay/use-playback-source"
+import { Media } from "@/components/limeplay/media"
+
+interface CustomVideoAsset extends Asset {
+ title?: string
+}
+
+interface CustomVideoPlayerProps {
+ autoLoad?: boolean
+ initialIndex?: number
+ loading?: UseAssetOptions
+ mediaProps?: Omit, "as" | "src">
+ source?: PlayerSource
+ sourceKey?: string
+}
+
+export function CustomVideoPlayer({
+ autoLoad,
+ initialIndex,
+ loading,
+ mediaProps,
+ source,
+ sourceKey,
+}: CustomVideoPlayerProps) {
+ return (
+
+
+
+
+ )
+}
+```
+
+This keeps future blocks predictable: consumers learn one `source` and `loading` contract, while block authors can still add block-specific UI props.
diff --git a/apps/www/package.json b/apps/www/package.json
index 939b3d89..6f40d822 100644
--- a/apps/www/package.json
+++ b/apps/www/package.json
@@ -10,8 +10,9 @@
"registry:dev": "REGISTRY_HOST=http://localhost:3000 bun --watch run ./scripts/registry-dev.mts --watch",
"validate:registries": "bun run ./scripts/validate-registries.ts",
"test:registry-install": "bun run scripts/test-registry-install.ts",
+ "docs:generate": "fumadocs-mdx",
"install:vercel": "bash ./scripts/vercel-install.sh",
- "postinstall": "fumadocs-mdx"
+ "postinstall": "true"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^1.0.39",
@@ -38,6 +39,7 @@
"@vercel/speed-insights": "^1.3.1",
"ai": "^5.0.101",
"async-retry": "^1.3.3",
+ "beautiful-mermaid": "^1.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -65,7 +67,7 @@
"remark-code-import": "^1.2.0",
"remark-gfm": "^4.0.1",
"remark-rehype": "^11.1.2",
- "shaka-player": "^4.16.32",
+ "shaka-player": "^4.16.34",
"tailwind-merge": "^3.6.0",
"ts-morph": "27.0.2",
"tw-animate-css": "^1.4.0",
diff --git a/apps/www/registry/collection/registry-blocks.ts b/apps/www/registry/collection/registry-blocks.ts
index 54e0fb36..eb8076f0 100644
--- a/apps/www/registry/collection/registry-blocks.ts
+++ b/apps/www/registry/collection/registry-blocks.ts
@@ -56,6 +56,10 @@ export const blocks: Registry["items"] = [
path: `${VIDEO_PLAYER_SRC_URL}/components/playlist.tsx`,
type: "registry:component",
},
+ {
+ path: `${VIDEO_PLAYER_SRC_URL}/components/playlist-navigation-controls.tsx`,
+ type: "registry:component",
+ },
{
path: `${VIDEO_PLAYER_SRC_URL}/components/captions-state-control.tsx`,
type: "registry:component",
@@ -214,6 +218,7 @@ export const blocks: Registry["items"] = [
"use-asset",
"use-media",
"use-playback-source",
+ "limeplay-logo",
"utils",
],
title: "Audio Player",
diff --git a/apps/www/registry/default/blocks/audio-player/components/audio-source.tsx b/apps/www/registry/default/blocks/audio-player/components/audio-source.tsx
index 9253865c..c78c3711 100644
--- a/apps/www/registry/default/blocks/audio-player/components/audio-source.tsx
+++ b/apps/www/registry/default/blocks/audio-player/components/audio-source.tsx
@@ -4,41 +4,51 @@ import * as React from "react"
import type {
Asset,
- GetAssetId,
- ResolveSource,
+ PlayerSource,
UseAssetOptions,
} from "@/registry/default/hooks/use-asset"
+import { AssetRecoveryAction } from "@/registry/default/hooks/use-asset"
import { PlaybackSourceController } from "@/registry/default/hooks/use-playback-source"
+export interface AudioAssetDisplayMetadata {
+ poster?: string
+ subtitle?: string
+ title: string
+}
+
export interface AudioPlayerAsset extends Asset {
+ albumName?: string
+ artistName?: string
+ artwork?: {
+ templateUrl?: string
+ url?: string
+ }
description?: string
duration?: number
+ features?: string[]
genre?: string
+ group?: string
+ images?: {
+ backdrop?: string
+ poster?: string
+ }
+ name?: string
playbackUrls?: PlaybackUrls
poster?: string
releaseYear?: number | string
+ subtitle?: string
title?: string
year?: number | string
}
-export interface AudioSourceContextValue {
- items: AudioPlayerAsset[]
-}
-
export interface AudioSourceProviderProps {
- asset?: AudioPlayerAsset
- assetOptions?: Omit<
- UseAssetOptions,
- "getAssetId" | "resolveSource"
- >
autoLoad?: boolean
children?: React.ReactNode
- getAssetId?: GetAssetId
initialIndex?: number
- mediaSrc?: string
- playlist?: AudioPlayerAsset[]
- resolveSource?: ResolveSource
+ loading?: UseAssetOptions
+ source?: PlayerSource
+ sourceKey?: string
}
export interface PlaybackUrls {
@@ -46,125 +56,134 @@ export interface PlaybackUrls {
secondary?: string
}
-const AudioSourceContext = React.createContext(
- null
-)
-
interface RawPlaybackResponse {
expires_at: string
url: string
}
export function AudioSourceProvider({
- asset,
- assetOptions,
autoLoad = true,
children,
- getAssetId,
initialIndex,
- mediaSrc,
- playlist,
- resolveSource,
+ loading,
+ source,
+ sourceKey,
}: AudioSourceProviderProps) {
- const items = React.useMemo(() => {
- if (playlist) return playlist
- if (asset) return [asset]
- return []
- }, [asset, playlist])
-
- const resolvedAssetOptions = React.useMemo>(
+ const resolvedLoading = React.useMemo>(
() => ({
- onLoadError: (_asset: AudioPlayerAsset, _error: unknown, { hasNext }) => {
- return hasNext ? "skip" : "stop"
+ ...loading,
+ getAssetId: (asset, context) =>
+ loading?.getAssetId?.(asset, context) ??
+ asset.id ??
+ asset.src ??
+ asset.playbackUrls?.primary,
+ recover: {
+ loadError: (_asset: AudioPlayerAsset, _error: unknown, { hasNext }) => {
+ return hasNext ? AssetRecoveryAction.Skip : AssetRecoveryAction.Stop
+ },
+ playbackError: async (
+ _asset: AudioPlayerAsset,
+ error: Error,
+ { currentTime }: { currentTime: number }
+ ) => {
+ if (isNetworkError(error)) {
+ return {
+ action: AssetRecoveryAction.Reload,
+ startTime: currentTime,
+ }
+ }
+
+ return { action: AssetRecoveryAction.Skip }
+ },
+ ...loading?.recover,
},
- onPlaybackError: async (
- _asset: AudioPlayerAsset,
- error: Error,
- { currentTime }: { currentTime: number }
- ): Promise<
- | { action: "reload"; startTime?: number }
- | { action: "skip" }
- | { action: "stop" }
- > => {
- if (isNetworkError(error)) {
- return { action: "reload", startTime: currentTime }
+ resolveSource: async (context) => {
+ if (loading?.resolveSource) return loading.resolveSource(context)
+
+ const { asset, signal } = context
+ if (asset.src) {
+ return {
+ config: asset.config,
+ src: asset.src,
+ }
}
- return { action: "skip" }
- },
- ...assetOptions,
- }),
- [assetOptions]
- )
- const resolvedGetAssetId = React.useCallback>(
- (asset, context) =>
- getAssetId?.(asset, context) ??
- asset.id ??
- asset.src ??
- asset.playbackUrls?.primary,
- [getAssetId]
- )
- const resolvedSource = React.useCallback>(
- async (context) => {
- if (resolveSource) return resolveSource(context)
+ if (!asset.playbackUrls?.primary) {
+ throw new Error(
+ "AudioPlayerAsset requires src or playbackUrls.primary"
+ )
+ }
+
+ const src = await fetchPlaybackUrl(asset.playbackUrls.primary, signal)
+ if (signal.aborted) {
+ throw new DOMException(
+ "Playback source request aborted",
+ "AbortError"
+ )
+ }
- const { asset, signal } = context
- if (asset.src) {
return {
config: asset.config,
- src: asset.src,
+ src,
}
- }
-
- if (!asset.playbackUrls?.primary) {
- throw new Error("AudioPlayerAsset requires src or playbackUrls.primary")
- }
-
- const src = await fetchPlaybackUrl(asset.playbackUrls.primary, signal)
- if (signal.aborted) {
- throw new DOMException("Playback source request aborted", "AbortError")
- }
-
- return {
- config: asset.config,
- src,
- }
- },
- [resolveSource]
- )
-
- const value = React.useMemo(
- () => ({
- items,
+ },
}),
- [items]
+ [loading]
)
return (
-
+ <>
{children}
-
+ >
)
}
-export function useAudioSource() {
- const context = React.useContext(AudioSourceContext)
-
- if (!context) {
- throw new Error("Missing AudioSourceProvider")
+export function getAudioAssetMetadata(
+ asset: AudioPlayerAsset | null | undefined,
+ fallbackTitle = "Unknown Title"
+): AudioAssetDisplayMetadata {
+ if (!asset) {
+ return {
+ title: fallbackTitle,
+ }
}
- return context
+ const releaseYear = asset.releaseYear ?? asset.year
+ const artistAlbum = joinDisplayParts([asset.artistName, asset.albumName])
+ const genreYear = joinDisplayParts([asset.genre, releaseYear])
+ const streamLabel = getStreamLabel(asset)
+
+ return {
+ poster:
+ firstNonEmpty(
+ asset.poster,
+ getArtworkUrl(asset.artwork),
+ asset.images?.poster,
+ asset.images?.backdrop
+ ) ?? undefined,
+ subtitle:
+ firstNonEmpty(
+ artistAlbum,
+ genreYear,
+ asset.subtitle,
+ streamLabel,
+ asset.description
+ ) ?? undefined,
+ title:
+ firstNonEmpty(
+ asset.title,
+ asset.name,
+ asset.albumName,
+ asset.description
+ ) ?? fallbackTitle,
+ }
}
async function fetchPlaybackUrl(
@@ -183,6 +202,39 @@ async function fetchPlaybackUrl(
return data.url
}
+function firstNonEmpty(
+ ...values: (null | number | string | undefined)[]
+): string | undefined {
+ for (const value of values) {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return String(value)
+ }
+
+ if (typeof value !== "string") continue
+
+ const trimmed = value.trim()
+ if (trimmed) return trimmed
+ }
+
+ return undefined
+}
+
+function getArtworkUrl(
+ artwork: AudioPlayerAsset["artwork"]
+): string | undefined {
+ const url = firstNonEmpty(artwork?.url, artwork?.templateUrl)
+ if (!url) return undefined
+
+ return url
+ .replaceAll("{w}", "80")
+ .replaceAll("{h}", "80")
+ .replaceAll("{f}", "jpg")
+}
+
+function getStreamLabel(asset: AudioPlayerAsset): string | undefined {
+ return joinDisplayParts([asset.group, ...(asset.features ?? [])])
+}
+
function isNetworkError(error: unknown) {
return (
error &&
@@ -191,3 +243,13 @@ function isNetworkError(error: unknown) {
(error as { category: number }).category === 1
)
}
+
+function joinDisplayParts(
+ values: (null | number | string | undefined)[]
+): string | undefined {
+ const parts = values
+ .map((value) => firstNonEmpty(value))
+ .filter((value): value is string => Boolean(value))
+
+ return parts.length > 0 ? parts.join(" • ") : undefined
+}
diff --git a/apps/www/registry/default/blocks/audio-player/components/controls.tsx b/apps/www/registry/default/blocks/audio-player/components/controls.tsx
index b9f6c5e3..ecfef831 100644
--- a/apps/www/registry/default/blocks/audio-player/components/controls.tsx
+++ b/apps/www/registry/default/blocks/audio-player/components/controls.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import { ActionControls } from "@/registry/default/blocks/audio-player/components/action-controls"
import { TimeLabels } from "@/registry/default/blocks/audio-player/components/fixed-timeline-control"
import { PlaybackControls } from "@/registry/default/blocks/audio-player/components/playback-controls"
@@ -8,12 +10,22 @@ import {
import { Playlist } from "@/registry/default/blocks/audio-player/components/playlist"
import { TrackInfo } from "@/registry/default/blocks/audio-player/components/track-info"
import { VolumeControl } from "@/registry/default/blocks/audio-player/components/volume-group-control"
+import {
+ AssetSourceType,
+ useAssetStore,
+} from "@/registry/default/hooks/use-asset"
+import { usePlaylistStore } from "@/registry/default/hooks/use-playlist"
export function PlayerControls() {
+ const sourceType = useAssetStore((state) => state.sourceType)
+ const queueLength = usePlaylistStore((state) => state.queue.length)
+ const isPlaylistMode =
+ sourceType === AssetSourceType.Playlist || queueLength > 1
+
return (
@@ -22,9 +34,13 @@ export function PlayerControls() {
-
-
-
+
+ {isPlaylistMode ? (
+ <>
+
+
+ >
+ ) : null}
)
diff --git a/apps/www/registry/default/blocks/audio-player/components/media-player.tsx b/apps/www/registry/default/blocks/audio-player/components/media-player.tsx
index 4499328c..90a493e0 100644
--- a/apps/www/registry/default/blocks/audio-player/components/media-player.tsx
+++ b/apps/www/registry/default/blocks/audio-player/components/media-player.tsx
@@ -24,53 +24,41 @@ import styles from "../audio-player.module.css"
export type { AudioPlayerAsset, PlaybackUrls }
export interface AudioPlayerProps {
- asset?: AudioSourceProviderProps["asset"]
- assetOptions?: AudioSourceProviderProps["assetOptions"]
autoLoad?: boolean
- autoPlay?: boolean
children?: ReactNode
className?: string
debug?: boolean
- getAssetId?: AudioSourceProviderProps["getAssetId"]
initialIndex?: number
+ loading?: AudioSourceProviderProps["loading"]
/**
* Props to pass to the underlying audio element.
*/
- mediaProps?: Omit<
- AudioHTMLAttributes,
- "as" | "autoPlay" | "className"
- >
- playlist?: AudioPlayerAsset[]
- resolveSource?: AudioSourceProviderProps["resolveSource"]
+ mediaProps?: Omit, "as" | "src">
+ source?: AudioSourceProviderProps["source"]
+ sourceKey?: string
}
export function AudioPlayer({
- asset,
- assetOptions,
autoLoad,
- autoPlay = false,
children,
className,
debug,
- getAssetId,
initialIndex,
+ loading,
mediaProps,
- playlist,
- resolveSource,
+ source,
+ sourceKey,
}: AudioPlayerProps = {}) {
- const { src: mediaSrc, ...safeMediaProps } = mediaProps ?? {}
+ const { className: mediaClassName, ...safeMediaProps } = mediaProps ?? {}
return (
)}
as="audio"
- autoPlay={autoPlay}
+ className={mediaClassName}
/>
diff --git a/apps/www/registry/default/blocks/audio-player/components/playback-controls.tsx b/apps/www/registry/default/blocks/audio-player/components/playback-controls.tsx
index fa27d261..52846041 100644
--- a/apps/www/registry/default/blocks/audio-player/components/playback-controls.tsx
+++ b/apps/www/registry/default/blocks/audio-player/components/playback-controls.tsx
@@ -16,7 +16,11 @@ import { RECOMMENDED_PLAYER_BUFFERING_THROTTLE_MS } from "@/registry/default/hoo
import { usePlaylist } from "@/registry/default/hooks/use-playlist"
import { PlaybackControl } from "@/registry/default/ui/playback-control"
-export function PlaybackControls() {
+export function PlaybackControls({
+ showNavigation = true,
+}: {
+ showNavigation?: boolean
+}) {
const status = usePlaybackStore((state) => state.status)
const { hasNext, hasPrevious, next, previous } = usePlaylist()
@@ -29,13 +33,15 @@ export function PlaybackControls() {
return (
-
+ {showNavigation ? (
+
+ ) : null}
-
+ {showNavigation ? (
+
+ ) : null}
)
}
diff --git a/apps/www/registry/default/blocks/audio-player/components/playback-mode-controls.tsx b/apps/www/registry/default/blocks/audio-player/components/playback-mode-controls.tsx
index 2901e400..fa09dab3 100644
--- a/apps/www/registry/default/blocks/audio-player/components/playback-mode-controls.tsx
+++ b/apps/www/registry/default/blocks/audio-player/components/playback-mode-controls.tsx
@@ -1,6 +1,9 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
+import { useEffect, useMemo } from "react"
+
+import type { RepeatMode } from "@/registry/default/hooks/use-playlist"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/default/blocks/audio-player/components/button"
@@ -16,19 +19,46 @@ import {
usePlaylistStore,
} from "@/registry/default/hooks/use-playlist"
-export function RepeatControl() {
- const { cycleRepeatMode } = usePlaylist()
+type RepeatControlVariant = "asset" | "playlist"
+
+const REPEAT_MODES: Record
= {
+ asset: ["off", "one"],
+ playlist: ["off", "all", "one"],
+}
+
+export function RepeatControl({
+ variant = "playlist",
+}: {
+ variant?: RepeatControlVariant
+}) {
+ const setRepeatMode = usePlaylistStore((state) => state.setRepeatMode)
const repeatMode = usePlaylistStore((state) => state.repeatMode)
- const isActive = repeatMode !== "off"
+ const modes = REPEAT_MODES[variant]
+ const normalizedRepeatMode = useMemo(
+ () => normalizeRepeatMode(repeatMode, modes),
+ [modes, repeatMode]
+ )
+ const isActive = normalizedRepeatMode !== "off"
+
+ useEffect(() => {
+ if (repeatMode === normalizedRepeatMode) return
+ setRepeatMode(normalizedRepeatMode)
+ }, [normalizedRepeatMode, repeatMode, setRepeatMode])
+
+ const handleCycleRepeatMode = () => {
+ const modeIndex = modes.indexOf(normalizedRepeatMode)
+ const nextMode = modes[(modeIndex + 1) % modes.length] ?? "off"
+ setRepeatMode(nextMode)
+ }
return (
)
}
+
+function normalizeRepeatMode(
+ mode: RepeatMode,
+ modes: RepeatMode[]
+): RepeatMode {
+ if (modes.includes(mode)) return mode
+ if (mode === "all" && modes.includes("one")) return "one"
+ return modes[0] ?? "off"
+}
diff --git a/apps/www/registry/default/blocks/audio-player/components/playlist.tsx b/apps/www/registry/default/blocks/audio-player/components/playlist.tsx
index de1cc938..af6f8ce1 100644
--- a/apps/www/registry/default/blocks/audio-player/components/playlist.tsx
+++ b/apps/www/registry/default/blocks/audio-player/components/playlist.tsx
@@ -4,18 +4,20 @@ import { CardsThreeIcon, PlayIcon } from "@phosphor-icons/react"
import { Volume2Icon } from "lucide-react"
import { useCallback, useMemo, useRef } from "react"
-import type { AudioPlayerAsset } from "@/registry/default/blocks/audio-player/components/audio-source"
-
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
-import { useAudioSource } from "@/registry/default/blocks/audio-player/components/audio-source"
+import {
+ type AudioPlayerAsset,
+ getAudioAssetMetadata,
+} from "@/registry/default/blocks/audio-player/components/audio-source"
import { Button } from "@/registry/default/blocks/audio-player/components/button"
import { usePlayerStore } from "@/registry/default/hooks/use-player"
import { usePlaylistStore } from "@/registry/default/hooks/use-playlist"
+import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo"
export function Playlist() {
const currentItem = usePlaylistStore(
@@ -31,7 +33,6 @@ export function Playlist() {
const skipToId = usePlaylistStore((state) => state.skipToId)
const scrollRef = useRef(null)
- const { items } = useAudioSource()
const orderedItems = useMemo(() => {
if (!shuffle || shuffleOrder.length === 0) return queue
@@ -42,21 +43,14 @@ export function Playlist() {
)
}, [queue, shuffle, shuffleOrder])
- const displayAssets = useMemo(() => {
- if (orderedItems.length > 0)
- return orderedItems.map((item) => ({
+ const displayAssets = useMemo(
+ () =>
+ orderedItems.map((item) => ({
asset: item.properties,
id: item.id,
- }))
- return items.map((asset, index) => ({
- asset,
- id:
- asset.id ??
- asset.src ??
- asset.playbackUrls?.primary ??
- `asset:${index}`,
- }))
- }, [orderedItems, items])
+ })),
+ [orderedItems]
+ )
const handleAssetSelect = useCallback(
(assetId: string) => {
@@ -161,6 +155,8 @@ function TrackRow({
preloaded: boolean
setSize: number
}) {
+ const metadata = getAudioAssetMetadata(asset, "Untitled track")
+
return (