From 554a0c5db08bd054e0ac47826a46d160a7091eb2 Mon Sep 17 00:00:00 2001 From: winoffrg Date: Sun, 31 May 2026 03:27:15 +0530 Subject: [PATCH 1/5] feat: product fixes, naming, usage, providers and cores --- apps/www/app/(home)/page.tsx | 8 +- apps/www/components/blocks/block-showcase.tsx | 17 +- apps/www/components/blocks/info-pane.tsx | 14 +- apps/www/components/blocks/preview-pane.tsx | 8 +- apps/www/components/hero-buttons.tsx | 4 +- apps/www/components/mdx-components.tsx | 46 ++ .../players/audio-player/demo-player.tsx | 40 ++ .../components/players/audio-player/demo.ts | 57 +++ .../audio-player/hover-player.tsx} | 31 +- .../players/video-player/demo-assets.ts | 49 +++ .../video-player}/player-container.tsx | 9 +- apps/www/content/docs/blocks/audio-player.mdx | 62 +++ .../www/content/docs/blocks/linear-player.mdx | 38 -- apps/www/content/docs/blocks/video-player.mdx | 60 +++ .../www/content/docs/blocks/youtube-music.mdx | 33 -- apps/www/content/docs/hooks/meta.json | 1 + apps/www/content/docs/hooks/use-asset.mdx | 90 ++-- .../docs/hooks/use-playback-source.mdx | 56 +++ apps/www/content/docs/quick-start.mdx | 2 +- apps/www/next-env.d.ts | 2 +- .../registry/collection/registry-blocks.ts | 132 ++---- .../www/registry/collection/registry-hooks.ts | 12 + .../audio-player/audio-player.module.css | 35 ++ .../components/action-controls.tsx | 97 +++++ .../audio-player/components/audio-source.tsx | 191 ++++++++ .../blocks/audio-player/components/button.tsx | 61 +++ .../audio-player/components/controls.tsx | 31 ++ .../components/fixed-timeline-control.tsx | 65 +++ .../blocks/audio-player/components/icons.tsx | 241 ++++++++++ .../audio-player/components/media-player.tsx | 71 +++ .../components/playback-controls.tsx | 131 ++++++ .../components/playback-mode-controls.tsx | 90 ++++ .../audio-player/components/playlist.tsx | 238 ++++++++++ .../audio-player/components/track-info.tsx | 53 +++ .../components/volume-group-control.tsx | 68 +++ .../audio-player/hooks/use-playlist-asset.ts | 34 ++ .../blocks/audio-player/lib/media-kit.ts | 26 ++ .../basic-player/components/media-element.tsx | 50 --- .../basic-player/components/media-player.tsx | 56 --- .../components/playback-state-control.tsx | 32 -- .../blocks/basic-player/lib/media-kit.ts | 12 - .../default/blocks/basic-player/page.tsx | 9 - .../linear-player/components/media-player.tsx | 75 ---- .../default/blocks/linear-player/page.tsx | 9 - .../components/bottom-controls.tsx | 14 +- .../components/button.tsx | 0 .../components/captions-state-control.tsx | 2 +- .../video-player/components/media-player.tsx | 109 +++++ .../components/pip-control.tsx | 2 +- .../components/playback-rate-control.tsx | 0 .../components/playback-state-control.tsx | 2 +- .../components/playlist.tsx | 92 +--- .../components/timeline-slider-control.tsx | 2 +- .../components/volume-group-control.tsx | 4 +- .../components/volume-slider-control.tsx | 0 .../components/volume-state-control.tsx | 2 +- .../lib/media-kit.ts | 0 .../default/blocks/video-player/page.tsx | 21 + .../ui/toggle.tsx | 0 .../picture-in-picture-control-demo.tsx | 2 +- apps/www/registry/default/hooks/use-asset.ts | 410 ++++++++++++++---- apps/www/registry/default/hooks/use-media.ts | 5 +- .../default/hooks/use-playback-source.ts | 96 ++++ apps/www/registry/default/hooks/use-player.ts | 3 +- .../registry/default/hooks/use-playlist.ts | 15 +- .../www/registry/default/ui/limeplay-logo.tsx | 10 +- .../registry/default/ui/media-provider.tsx | 28 +- apps/www/registry/default/ui/media.tsx | 2 +- apps/www/registry/pro | 2 +- apps/www/scripts/test-registry-install.ts | 28 +- apps/www/scripts/validate-registries.ts | 2 +- apps/www/vercel.json | 40 ++ prompts/ide.md | 16 +- 73 files changed, 2659 insertions(+), 696 deletions(-) create mode 100644 apps/www/components/players/audio-player/demo-player.tsx create mode 100644 apps/www/components/players/audio-player/demo.ts rename apps/www/components/{youtube-music-hover-player.tsx => players/audio-player/hover-player.tsx} (61%) create mode 100644 apps/www/components/players/video-player/demo-assets.ts rename apps/www/components/{ => players/video-player}/player-container.tsx (89%) create mode 100644 apps/www/content/docs/blocks/audio-player.mdx delete mode 100644 apps/www/content/docs/blocks/linear-player.mdx create mode 100644 apps/www/content/docs/blocks/video-player.mdx delete mode 100644 apps/www/content/docs/blocks/youtube-music.mdx create mode 100644 apps/www/content/docs/hooks/use-playback-source.mdx create mode 100644 apps/www/registry/default/blocks/audio-player/audio-player.module.css create mode 100644 apps/www/registry/default/blocks/audio-player/components/action-controls.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/audio-source.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/button.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/controls.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/fixed-timeline-control.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/icons.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/media-player.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/playback-controls.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/playback-mode-controls.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/playlist.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/track-info.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/components/volume-group-control.tsx create mode 100644 apps/www/registry/default/blocks/audio-player/hooks/use-playlist-asset.ts create mode 100644 apps/www/registry/default/blocks/audio-player/lib/media-kit.ts delete mode 100644 apps/www/registry/default/blocks/basic-player/components/media-element.tsx delete mode 100644 apps/www/registry/default/blocks/basic-player/components/media-player.tsx delete mode 100644 apps/www/registry/default/blocks/basic-player/components/playback-state-control.tsx delete mode 100644 apps/www/registry/default/blocks/basic-player/lib/media-kit.ts delete mode 100644 apps/www/registry/default/blocks/basic-player/page.tsx delete mode 100644 apps/www/registry/default/blocks/linear-player/components/media-player.tsx delete mode 100644 apps/www/registry/default/blocks/linear-player/page.tsx rename apps/www/registry/default/blocks/{linear-player => video-player}/components/bottom-controls.tsx (56%) rename apps/www/registry/default/blocks/{linear-player => video-player}/components/button.tsx (100%) rename apps/www/registry/default/blocks/{linear-player => video-player}/components/captions-state-control.tsx (87%) create mode 100644 apps/www/registry/default/blocks/video-player/components/media-player.tsx rename apps/www/registry/default/blocks/{linear-player => video-player}/components/pip-control.tsx (89%) rename apps/www/registry/default/blocks/{linear-player => video-player}/components/playback-rate-control.tsx (100%) rename apps/www/registry/default/blocks/{linear-player => video-player}/components/playback-state-control.tsx (91%) rename apps/www/registry/default/blocks/{linear-player => video-player}/components/playlist.tsx (52%) rename apps/www/registry/default/blocks/{linear-player => video-player}/components/timeline-slider-control.tsx (97%) rename apps/www/registry/default/blocks/{linear-player => video-player}/components/volume-group-control.tsx (62%) rename apps/www/registry/default/blocks/{linear-player => video-player}/components/volume-slider-control.tsx (100%) rename apps/www/registry/default/blocks/{linear-player => video-player}/components/volume-state-control.tsx (90%) rename apps/www/registry/default/blocks/{linear-player => video-player}/lib/media-kit.ts (100%) create mode 100644 apps/www/registry/default/blocks/video-player/page.tsx rename apps/www/registry/default/blocks/{linear-player => video-player}/ui/toggle.tsx (100%) create mode 100644 apps/www/registry/default/hooks/use-playback-source.ts diff --git a/apps/www/app/(home)/page.tsx b/apps/www/app/(home)/page.tsx index f7beaffa..6a894247 100644 --- a/apps/www/app/(home)/page.tsx +++ b/apps/www/app/(home)/page.tsx @@ -4,9 +4,9 @@ import { FeatureTrailSection } from "@/components/feature-trail-section" import { FeatureGrid } from "@/components/features" import { Hero } from "@/components/hero" import { ImmersiveScrollPlayer } from "@/components/immersive-scroll-player" -import { PlayerContainer } from "@/components/player-container" +import { AudioPlayerHover } from "@/components/players/audio-player/hover-player" +import { VideoPlayerContainer } from "@/components/players/video-player/player-container" import { ScrollIndicator } from "@/components/scroll-indicator" -import { YouTubeMusicHoverPlayer } from "@/components/youtube-music-hover-player" export default function Home() { return ( @@ -21,7 +21,7 @@ export default function Home() { /> } > - + @@ -71,7 +71,7 @@ export default function Home() {

- +
( -
+
- +
), }, - "youtube-music": { + "video-player": { component: () => ( -
+
- +
diff --git a/apps/www/components/blocks/info-pane.tsx b/apps/www/components/blocks/info-pane.tsx index 56915424..0cf54ae2 100644 --- a/apps/www/components/blocks/info-pane.tsx +++ b/apps/www/components/blocks/info-pane.tsx @@ -24,8 +24,18 @@ export function BlockInfoPane({ `} > - {title} - {description} + + {title} + + + {description} + {content}
diff --git a/apps/www/components/blocks/preview-pane.tsx b/apps/www/components/blocks/preview-pane.tsx index a06cbb24..14beda1a 100644 --- a/apps/www/components/blocks/preview-pane.tsx +++ b/apps/www/components/blocks/preview-pane.tsx @@ -25,8 +25,8 @@ export function BlockPreviewWithToolbar({ const [reloadKey, setReloadKey] = useState(0) const panelRef = useRef(null) const [panelRect, setPanelRect] = useState({ - height: 0, - left: 0, + height: "100%", + left: "100%", top: 0, width: 0, }) @@ -37,8 +37,8 @@ export function BlockPreviewWithToolbar({ if (!panelRef.current) return const rect = panelRef.current.getBoundingClientRect() setPanelRect({ - height: rect.height, - left: rect.left, + height: rect.height as any, + left: rect.left as any, top: rect.top, width: rect.width, }) diff --git a/apps/www/components/hero-buttons.tsx b/apps/www/components/hero-buttons.tsx index 7a8ab326..dedc6d2a 100644 --- a/apps/www/components/hero-buttons.tsx +++ b/apps/www/components/hero-buttons.tsx @@ -5,7 +5,7 @@ import { motion } from "motion/react" import Link from "next/link" import { useCopyToClipboard } from "react-use" -const command = "npx shadcn add @limeplay/linear-player" +const command = "npx shadcn add @limeplay/video-player" const MotionLink = motion.create(Link) export default function HeroButtons() { @@ -84,7 +84,7 @@ export default function HeroButtons() { hover:bg-primary/90 md:flex `} - href="/blocks/linear-player" + href="/blocks/video-player" initial={{ padding: "0px 20px" }} transition={{ bounce: 0.6, diff --git a/apps/www/components/mdx-components.tsx b/apps/www/components/mdx-components.tsx index a86fa5b5..8e36d5bf 100644 --- a/apps/www/components/mdx-components.tsx +++ b/apps/www/components/mdx-components.tsx @@ -1,4 +1,5 @@ import type { MDXComponents } from "mdx/types" +import type { ReactNode } from "react" import { createGenerator } from "fumadocs-typescript" import { AutoTypeTable } from "fumadocs-typescript/ui" @@ -17,7 +18,9 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { ), ...TabsComponents, + Attribution, ComponentPreview, + License, pre: ({ ref: _ref, ...props }: React.ComponentPropsWithRef) => (
{props.children}
@@ -26,3 +29,46 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { ...components, } } + +function Attribution({ + children, + href, + name, +}: { + children?: ReactNode + href?: string + name: string +}) { + return ( +
+

Attribution

+

+ Inspired by{" "} + {href ? ( + + {name} + + ) : ( + name + )} + . +

+ {children} +
+ ) +} + +function License() { + return ( +
+

License & Usage

+
    +
  • Free to use and modify in personal and commercial projects.
  • +
  • Attribution to Limeplay is appreciated but not required.
  • +
  • + Do not resell the registry item as a standalone component library. +
  • +
+
+ ) +} diff --git a/apps/www/components/players/audio-player/demo-player.tsx b/apps/www/components/players/audio-player/demo-player.tsx new file mode 100644 index 00000000..e0f2d66f --- /dev/null +++ b/apps/www/components/players/audio-player/demo-player.tsx @@ -0,0 +1,40 @@ +"use client" + +import { useEffect, useState } from "react" + +import type { AudioPlayerAsset } from "@/registry/default/blocks/audio-player/components/media-player" + +import { AudioPlayer } from "@/registry/default/blocks/audio-player/components/media-player" + +import { + AUDIO_PLAYER_DEMO_PLAYLIST_ID, + fetchAudioPlayerDemoPlaylist, +} from "./demo" + +export interface AudioPlayerDemoProps { + playlistId?: string +} + +export function AudioPlayerDemo({ + playlistId = AUDIO_PLAYER_DEMO_PLAYLIST_ID, +}: AudioPlayerDemoProps) { + const [playlist, setPlaylist] = useState([]) + + useEffect(() => { + const abortController = new AbortController() + + void fetchAudioPlayerDemoPlaylist(playlistId, abortController.signal).then( + (items) => { + if (!abortController.signal.aborted) { + setPlaylist(items) + } + } + ) + + return () => { + abortController.abort() + } + }, [playlistId]) + + return +} diff --git a/apps/www/components/players/audio-player/demo.ts b/apps/www/components/players/audio-player/demo.ts new file mode 100644 index 00000000..6584a334 --- /dev/null +++ b/apps/www/components/players/audio-player/demo.ts @@ -0,0 +1,57 @@ +import type { + AudioPlayerAsset, + PlaybackUrls, +} from "@/registry/default/blocks/audio-player/components/media-player" + +export const AUDIO_PLAYER_DEMO_PLAYLIST_ID = "324531068" + +const API_BASE_URL = "https://limeplay.winoff.workers.dev/api/playlist" + +export interface AudioPlayerPlaylistApiResponse { + cached_at: string + expires_at: string + items: AudioPlayerPlaylistAssetItem[] +} + +export interface AudioPlayerPlaylistAssetItem { + artwork_url: string + description?: string + duration: number + full_duration: number + genre?: string + has_downloads_left: boolean + label_name?: null | string + playback: PlaybackUrls + tag_list?: string + title: string + urn: string + waveform_url?: string +} + +export async function fetchAudioPlayerDemoPlaylist( + playlistId: string, + signal: AbortSignal +): Promise { + const response = await fetch(`${API_BASE_URL}/${playlistId}`, { signal }) + + if (!response.ok) { + throw new Error(`Failed to fetch playlist: ${response.statusText}`) + } + + const data: AudioPlayerPlaylistApiResponse = await response.json() + return data.items.map(toAudioPlayerAsset) +} + +function toAudioPlayerAsset( + item: AudioPlayerPlaylistAssetItem +): AudioPlayerAsset { + return { + description: item.description, + duration: item.duration, + genre: item.genre, + id: item.urn, + playbackUrls: item.playback, + poster: item.artwork_url, + title: item.title, + } +} diff --git a/apps/www/components/youtube-music-hover-player.tsx b/apps/www/components/players/audio-player/hover-player.tsx similarity index 61% rename from apps/www/components/youtube-music-hover-player.tsx rename to apps/www/components/players/audio-player/hover-player.tsx index c00e3a59..60abefd6 100644 --- a/apps/www/components/youtube-music-hover-player.tsx +++ b/apps/www/components/players/audio-player/hover-player.tsx @@ -1,13 +1,14 @@ "use client" -import { SquareArrowOutUpRightIcon } from "lucide-react" +import { AudioLinesIcon, SquareArrowOutUpRightIcon } from "lucide-react" import { motion } from "motion/react" import Link from "next/link" import { Button } from "@/components/ui/button" -import { YouTubeMusicPlayer } from "@/registry/pro/blocks/youtube-music/components/media-player" -export function YouTubeMusicHoverPlayer() { +import { AudioPlayerDemo } from "./demo-player" + +export function AudioPlayerHover() { return (
- - - - - + + + - Music + Audio
- + ) } diff --git a/apps/www/components/players/video-player/demo-assets.ts b/apps/www/components/players/video-player/demo-assets.ts new file mode 100644 index 00000000..dbdab298 --- /dev/null +++ b/apps/www/components/players/video-player/demo-assets.ts @@ -0,0 +1,49 @@ +import type shaka from "shaka-player" + +import type { VideoPlayerAsset } from "@/registry/default/blocks/video-player/components/media-player" + +export const VIDEO_PLAYER_DEMO_ASSETS: VideoPlayerAsset[] = [ + { + config: { + drm: { + advanced: { + "com.widevine.alpha": { + serverCertificateUri: + "https://cwip-shaka-proxy.appspot.com/service-cert", + }, + }, + servers: { + "com.widevine.alpha": "https://cwip-shaka-proxy.appspot.com/no_auth", + }, + } as unknown as shaka.extern.DrmConfiguration, + } as shaka.extern.PlayerConfiguration, + description: + "A Blender Foundation short film, protected by Widevine encryption", + id: "sintel", + poster: "https://storage.googleapis.com/shaka-asset-icons/sintel.png", + src: "https://storage.googleapis.com/shaka-demo-assets/sintel-widevine/dash.mpd", + title: "Blender Foundation - Sintel", + }, + { + description: "Media Tailor HLS Stream", + id: "sing2", + poster: "https://storage.googleapis.com/shaka-asset-icons/sing.png", + src: "https://ad391cc0d55b44c6a86d232548adc225.mediatailor.us-east-1.amazonaws.com/v1/master/d02fedbbc5a68596164208dd24e9b48aa60dadc7/singssai/master.m3u8", + title: "Sing 2 Trailer", + }, + { + description: "A Blender Foundation short film, Media Tailor Live DASH", + id: "bbb", + poster: + "https://storage.googleapis.com/shaka-asset-icons/big_buck_bunny.png", + src: "https://d305rncpy6ne2q.cloudfront.net/v1/dash/94063eadf7d8c56e9e2edd84fdf897826a70d0df/SFP-MediaTailor-Live-HLS-DASH/channel/sfp-channel1/dash.mpd", + title: "Big Buck Bunny", + }, + { + description: "HLS Video", + id: "natgeo", + poster: "https://demo.theoplayer.com/hubfs/videos/natgeo/poster.jpg", + src: "https://demo.theoplayer.com/hubfs/videos/natgeo/playlist.m3u8", + title: "National Geographic - VR equirectangular", + }, +] diff --git a/apps/www/components/player-container.tsx b/apps/www/components/players/video-player/player-container.tsx similarity index 89% rename from apps/www/components/player-container.tsx rename to apps/www/components/players/video-player/player-container.tsx index c66fc17a..308ac5ca 100644 --- a/apps/www/components/player-container.tsx +++ b/apps/www/components/players/video-player/player-container.tsx @@ -10,9 +10,11 @@ import { Button } from "@/components/ui/button" import { useIsMobile } from "@/hooks/use-mobile" import { useOrientation } from "@/hooks/use-orientation" import { cn } from "@/lib/utils" -import { LinearMediaPlayer } from "@/registry/default/blocks/linear-player/components/media-player" +import { VideoPlayer } from "@/registry/default/blocks/video-player/components/media-player" -export function PlayerContainer() { +import { VIDEO_PLAYER_DEMO_ASSETS } from "./demo-assets" + +export function VideoPlayerContainer() { const isMobile = useIsMobile() const { isPortrait } = useOrientation() const isMobilePortrait = isMobile && isPortrait @@ -21,7 +23,7 @@ export function PlayerContainer() { return ( <> {isMobilePortrait && } - diff --git a/apps/www/content/docs/blocks/audio-player.mdx b/apps/www/content/docs/blocks/audio-player.mdx new file mode 100644 index 00000000..146c2eed --- /dev/null +++ b/apps/www/content/docs/blocks/audio-player.mdx @@ -0,0 +1,62 @@ +--- +title: Audio Player +description: A compact audio player with playlist support, timeline scrubbing, volume controls, repeat and shuffle modes, and track artwork. +component: audio-player +preview: audio-player +registry: default +status: free +--- + +## Installation + +```npm +npx shadcn add @limeplay/audio-player +``` + +## Usage + +```tsx +import { + AudioPlayer, + type AudioPlayerAsset, +} from "@/components/audio-player/components/media-player" + +const playlist: AudioPlayerAsset[] = [ + { + duration: 372_000, + 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 +} +``` + +## Features + +- Playlist queue with track artwork, title, genre, and duration. +- Fixed timeline with elapsed, duration, hover time, and scrub support. +- 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. + +## 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. + +## API Reference + + + + + + diff --git a/apps/www/content/docs/blocks/linear-player.mdx b/apps/www/content/docs/blocks/linear-player.mdx deleted file mode 100644 index 1ad8654d..00000000 --- a/apps/www/content/docs/blocks/linear-player.mdx +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Video Player -description: A modern seamless flat media player with clean interface design. -component: linear-player -preview: linear-player -registry: default -status: free ---- - -```npm -npx shadcn add @limeplay/linear-player -``` - -## Interaction Type - -- Hover over the player to reveal the transport controls overlay. -- Drag or click the timeline slider to scrub through the video. -- Use the volume group control to adjust level or toggle mute. -- Toggle captions, playback rate, and picture-in-picture from the bottom controls. -- Open the playlist panel to browse and switch between queued items. - -## How to Use - -```tsx -import { LinearMediaPlayer } from "@/components/linear-player/media-player" - -export function DemoLinearPlayer() { - return ( -
- -
- ) -} -``` - -## Attribution - -[Linear](https://linear.app/quality) diff --git a/apps/www/content/docs/blocks/video-player.mdx b/apps/www/content/docs/blocks/video-player.mdx new file mode 100644 index 00000000..3f261ecb --- /dev/null +++ b/apps/www/content/docs/blocks/video-player.mdx @@ -0,0 +1,60 @@ +--- +title: Video Player +description: A polished video player with playlist support, captions, picture-in-picture, playback speed, preloading, and smooth overlay controls. +component: video-player +preview: video-player +registry: default +status: free +--- + +## Installation + +```npm +npx shadcn add @limeplay/video-player +``` + +## Usage + +```tsx +import { + VideoPlayer, + type VideoPlayerAsset, +} from "@/components/video-player/components/media-player" + +const playlist: VideoPlayerAsset[] = [ + { + id: "sing-2", + src: "https://ad391cc0d55b44c6a86d232548adc225.mediatailor.us-east-1.amazonaws.com/v1/master/d02fedbbc5a68596164208dd24e9b48aa60dadc7/singssai/master.m3u8", + title: "Sing 2 Trailer", + }, +] + +export function Player() { + return +} +``` + +## Features + +- Playlist queue with active item switching. +- Asset preloading when users hover playlist items. +- 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. + +## 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. + +## API Reference + + + + + + diff --git a/apps/www/content/docs/blocks/youtube-music.mdx b/apps/www/content/docs/blocks/youtube-music.mdx deleted file mode 100644 index 0b2170d1..00000000 --- a/apps/www/content/docs/blocks/youtube-music.mdx +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: Audio Player -description: A pro registry block that recreates the compact YouTube Music playback rail with playlist, timeline, and transport controls. -component: youtube-music -preview: youtube-music -registry: pro -status: pro ---- - -```npm -npx shadcn add @limeplay/pro/youtube-music -``` - -## Interaction Type - -- Try hovering transport actions and playback mode controls. -- Open the playlist panel to inspect the queue-driven layout. -- Drag or click the fixed timeline to scrub the active track. -- Use the volume control group to test mute and level state. - -## How to Use - -```tsx -import { YouTubeMusicPlayer } from "@/components/youtube-music/media-player" - -export function DemoYouTubeMusic() { - return ( -
- -
- ) -} -``` diff --git a/apps/www/content/docs/hooks/meta.json b/apps/www/content/docs/hooks/meta.json index 6a3ca26f..fe46b196 100644 --- a/apps/www/content/docs/hooks/meta.json +++ b/apps/www/content/docs/hooks/meta.json @@ -3,6 +3,7 @@ "description": "Player state and utility hooks", "pages": [ "use-asset", + "use-playback-source", "use-player", "use-playback", "use-volume", diff --git a/apps/www/content/docs/hooks/use-asset.mdx b/apps/www/content/docs/hooks/use-asset.mdx index ef0bbf9a..0ab35803 100644 --- a/apps/www/content/docs/hooks/use-asset.mdx +++ b/apps/www/content/docs/hooks/use-asset.mdx @@ -1,6 +1,6 @@ --- title: use-asset -description: Higher-order feature combining player and playlist for managing media assets. +description: Asset playback orchestration for player, playlist, preloading, and source resolution. --- ## Installation @@ -9,16 +9,19 @@ description: Higher-order feature combining player and playlist for managing med npx shadcn add @limeplay/use-asset ``` -Register the feature (requires `playerFeature`, `playlistFeature`, and `playbackFeature`): +Register the feature after `playerFeature`, `playlistFeature`, and `playbackFeature`: ```tsx title="lib/media.ts" "use client" import { assetFeature } from "@/hooks/limeplay/use-asset" +import { playbackFeature } from "@/hooks/limeplay/use-playback" +import { playerFeature } from "@/hooks/limeplay/use-player" +import { playlistFeature } from "@/hooks/limeplay/use-playlist" +import { createMediaKit } from "@/components/limeplay/media-provider" createMediaKit({ features: [ - ..., playerFeature(), playlistFeature(), playbackFeature(), @@ -27,41 +30,70 @@ createMediaKit({ }) ``` -## useAsset Hook +## Usage ```tsx import { useAsset, type Asset } from "@/hooks/limeplay/use-asset" -const { loadAsset, loadPlaylist, next, previous, hasNext, currentItem } = - useAsset({ - onAssetLoaded: (asset) => console.log("Loaded:", asset.title), - onLoadError: (asset, error, ctx) => { - console.error("Failed:", asset.title, error) - return "skip" // or "retry" or "stop" - }, +interface VideoAsset extends Asset { + playbackUrl: string + slug: string +} + +export function PlaylistController({ assets }: { assets: VideoAsset[] }) { + const { currentItem, loadPlaylist, next, previous } = useAsset({ + getAssetId: (asset) => asset.slug, + resolveSource: ({ asset }) => ({ + config: asset.config, + src: asset.playbackUrl, + }), }) -// Load a single asset -loadAsset({ - id: "movie-1", - src: "https://storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd", - title: "Angel One", -}) - -// Load a playlist -loadPlaylist([asset1, asset2, asset3]) + return ( +
+ + + + {currentItem?.properties.title} +
+ ) +} ``` +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. + ### Asset Interface -| Field | Type | Required | Description | -| ------------- | ---------------------------------- | -------- | ---------------------- | -| `id` | `string` | Yes | Unique identifier | -| `src` | `string` | Yes | Media source URL | -| `title` | `string` | No | Display title | -| `description` | `string` | No | Description | -| `poster` | `string` | No | Poster image URL | -| `config` | `shaka.extern.PlayerConfiguration` | No | Per-asset Shaka config | +| Field | Type | Required | Description | +| ------------- | ---------------------------------- | -------- | -------------------------------- | +| `id` | `string` | No | Stable identifier for the asset. | +| `src` | `string` | No | Media source URL. | +| `config` | `shaka.extern.PlayerConfiguration` | No | Per-asset Shaka config. | +| `title` | `string` | No | Display title. | +| `description` | `string` | No | Description. | +| `poster` | `string` | No | Poster image URL. | + +If `id` is omitted, `useAsset` derives one from `getAssetId` or `src`. Assets without `src` should provide `resolveSource`. + +### Source Resolution + +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, + } + }, +}) +``` + +`operation` is `"load"` or `"preload"`, so source endpoints can avoid expensive work during preloading when needed. ### Options @@ -77,4 +109,4 @@ loadPlaylist([asset1, asset2, asset3]) name="UseAssetReturn" /> -`useAsset` orchestrates `usePlayer` and `usePlaylist` — it automatically loads assets when the playlist advances, handles preloading, auto-advances on playback end, and provides error recovery. +`useAsset` orchestrates `usePlayer` and `usePlaylist`. It owns load cancellation, playlist-driven loading, preloading, auto-advance on playback end, and error recovery. diff --git a/apps/www/content/docs/hooks/use-playback-source.mdx b/apps/www/content/docs/hooks/use-playback-source.mdx new file mode 100644 index 00000000..10358941 --- /dev/null +++ b/apps/www/content/docs/hooks/use-playback-source.mdx @@ -0,0 +1,56 @@ +--- +title: use-playback-source +description: Normalizes block source props into useAsset playback state. +--- + +## Installation + +```npm +npx shadcn add @limeplay/use-playback-source +``` + + + Requires [`assetFeature`](/docs/hooks/use-asset) registered in your + [`createMediaKit`](/docs/components/media-provider). + + +## Usage + +Use `PlaybackSourceController` inside a block to load exactly one source model: `asset`, `playlist`, or `mediaSrc`. + +```tsx +import { PlaybackSourceController } from "@/hooks/limeplay/use-playback-source" + +export function SourceController({ src }: { src?: string }) { + return +} +``` + +For typed assets, pass `asset`, `playlist`, `getAssetId`, and `resolveSource` through to `useAsset`. + +```tsx +import type { Asset } from "@/hooks/limeplay/use-asset" +import { PlaybackSourceController } from "@/hooks/limeplay/use-playback-source" + +interface VideoAsset extends Asset { + playbackUrl: string + slug: string +} + +export function VideoSource({ playlist }: { playlist: VideoAsset[] }) { + return ( + asset.slug} + playlist={playlist} + resolveSource={({ asset }) => asset.playbackUrl} + /> + ) +} +``` + +## API Reference + + diff --git a/apps/www/content/docs/quick-start.mdx b/apps/www/content/docs/quick-start.mdx index 16fcc962..3f4b31cf 100644 --- a/apps/www/content/docs/quick-start.mdx +++ b/apps/www/content/docs/quick-start.mdx @@ -20,7 +20,7 @@ npx shadcn@latest init The fastest path — install a complete, ready-to-use player: ```npm -npx shadcn add limeplay/linear-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. diff --git a/apps/www/next-env.d.ts b/apps/www/next-env.d.ts index 7a70f65a..c4b7818f 100644 --- a/apps/www/next-env.d.ts +++ b/apps/www/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts" +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/www/registry/collection/registry-blocks.ts b/apps/www/registry/collection/registry-blocks.ts index 990fcc1c..13dee764 100644 --- a/apps/www/registry/collection/registry-blocks.ts +++ b/apps/www/registry/collection/registry-blocks.ts @@ -1,6 +1,6 @@ import { type Registry } from "shadcn/schema" -const BASE_SRC_URL = "blocks/linear-player" +const VIDEO_PLAYER_SRC_URL = "blocks/video-player" export const blocks: Registry["items"] = [ { @@ -13,68 +13,68 @@ export const blocks: Registry["items"] = [ "shaka-player@^4", "lodash.clamp", ], - description: "Modern seamless flat linear.app Media Player", + description: "Modern seamless flat video player", files: [ { - path: `${BASE_SRC_URL}/page.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/page.tsx`, target: "app/player/page.tsx", type: "registry:page", }, { - path: `${BASE_SRC_URL}/components/media-player.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/media-player.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/volume-state-control.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/volume-state-control.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/playback-state-control.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/playback-state-control.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/volume-slider-control.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/volume-slider-control.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/timeline-slider-control.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/timeline-slider-control.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/bottom-controls.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/bottom-controls.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/button.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/button.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/lib/media-kit.ts`, + path: `${VIDEO_PLAYER_SRC_URL}/lib/media-kit.ts`, type: "registry:lib", }, { - path: `${BASE_SRC_URL}/components/playlist.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/playlist.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/captions-state-control.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/captions-state-control.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/playback-rate-control.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/playback-rate-control.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/ui/toggle.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/ui/toggle.tsx`, target: "components/ui/toggle.tsx", type: "registry:ui", }, { - path: `${BASE_SRC_URL}/components/volume-group-control.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/volume-group-control.tsx`, type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/pip-control.tsx`, + path: `${VIDEO_PLAYER_SRC_URL}/components/pip-control.tsx`, type: "registry:component", }, ], @@ -84,7 +84,7 @@ export const blocks: Registry["items"] = [ src: "https://ad391cc0d55b44c6a86d232548adc225.mediatailor.us-east-1.amazonaws.com/v1/master/d02fedbbc5a68596164208dd24e9b48aa60dadc7/singssai/master.m3u8", }, }, - name: "linear-player", + name: "video-player", registryDependencies: [ "dropdown-menu", "player-layout", @@ -112,134 +112,88 @@ export const blocks: Registry["items"] = [ "use-picture-in-picture", "use-playlist", "use-asset", + "use-media", + "use-playback-source", ], type: "registry:block", }, { author: "Rohan Gupta (@winoffrg)", - dependencies: ["@phosphor-icons/react", "zustand", "shaka-player@^4"], - description: "Limeplay Basic Player", - files: [ - { - path: `blocks/basic-player/page.tsx`, - target: "app/player/page.tsx", - type: "registry:page", - }, - { - path: `blocks/basic-player/components/media-player.tsx`, - type: "registry:component", - }, - { - path: `blocks/basic-player/components/playback-state-control.tsx`, - type: "registry:component", - }, - { - path: `blocks/basic-player/components/media-element.tsx`, - type: "registry:component", - }, - { - path: `blocks/basic-player/lib/media-kit.ts`, - type: "registry:lib", - }, - ], - meta: { - iframeHeight: "750px", - props: { - src: "https://ad391cc0d55b44c6a86d232548adc225.mediatailor.us-east-1.amazonaws.com/v1/master/d02fedbbc5a68596164208dd24e9b48aa60dadc7/singssai/master.m3u8", - }, - }, - name: "basic-player", - registryDependencies: [ - "media-provider", - "player-layout", - "root-container", - "fallback-poster", - "limeplay-logo", - "media", - "utils", - "button", - "playback-control", - "use-player", - "use-playback", - ], - type: "registry:block", - }, - { - author: "Rohan Gupta (@winoffrg)", - categories: ["pro"], dependencies: [ "@phosphor-icons/react", "@radix-ui/react-slot", - "async-retry", "motion", "zustand", "shaka-player@^4", ], - description: "YouTube Music style audio player with playlist support", - devDependencies: ["@types/async-retry"], + description: "Compact audio player with playlist support", files: [ { - path: "blocks/youtube-music/lib/media-kit.ts", + path: "blocks/audio-player/lib/media-kit.ts", type: "registry:lib", }, { - path: "blocks/youtube-music/components/media-player.tsx", + path: "blocks/audio-player/components/media-player.tsx", + type: "registry:component", + }, + { + path: "blocks/audio-player/components/audio-source.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/components/controls.tsx", + path: "blocks/audio-player/components/controls.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/components/playback-controls.tsx", + path: "blocks/audio-player/components/playback-controls.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/components/action-controls.tsx", + path: "blocks/audio-player/components/action-controls.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/components/playback-mode-controls.tsx", + path: "blocks/audio-player/components/playback-mode-controls.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/components/volume-group-control.tsx", + path: "blocks/audio-player/components/volume-group-control.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/components/fixed-timeline-control.tsx", + path: "blocks/audio-player/components/fixed-timeline-control.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/components/track-info.tsx", + path: "blocks/audio-player/components/track-info.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/components/playlist.tsx", + path: "blocks/audio-player/components/playlist.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/components/icons.tsx", + path: "blocks/audio-player/components/icons.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/hooks/use-playlist-asset.ts", + path: "blocks/audio-player/hooks/use-playlist-asset.ts", type: "registry:hook", }, { - path: "blocks/youtube-music/components/button.tsx", + path: "blocks/audio-player/components/button.tsx", type: "registry:component", }, { - path: "blocks/youtube-music/youtube-music.module.css", - target: "components/youtube-music/youtube-music.module.css", + path: "blocks/audio-player/audio-player.module.css", + target: "components/audio-player/audio-player.module.css", type: "registry:style", }, ], meta: { iframeHeight: "100px", }, - name: "youtube-music", + name: "audio-player", registryDependencies: [ "media-provider", "media", @@ -257,9 +211,11 @@ export const blocks: Registry["items"] = [ "use-captions", "use-playlist", "use-asset", + "use-media", + "use-playback-source", "utils", ], - title: "YouTube Music Audio Player", + title: "Audio Player", type: "registry:block", }, ] diff --git a/apps/www/registry/collection/registry-hooks.ts b/apps/www/registry/collection/registry-hooks.ts index fab6f616..82813084 100644 --- a/apps/www/registry/collection/registry-hooks.ts +++ b/apps/www/registry/collection/registry-hooks.ts @@ -108,6 +108,18 @@ export const hooks: Registry["items"] = [ ], type: "registry:hook", }, + { + files: [ + { + path: "hooks/use-playback-source.ts", + target: `${TARGET_BASE_PATH}/use-playback-source.ts`, + type: "registry:hook", + }, + ], + name: "use-playback-source", + registryDependencies: ["use-asset", "use-player"], + type: "registry:hook", + }, { files: [ { diff --git a/apps/www/registry/default/blocks/audio-player/audio-player.module.css b/apps/www/registry/default/blocks/audio-player/audio-player.module.css new file mode 100644 index 00000000..bc7a3842 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/audio-player.module.css @@ -0,0 +1,35 @@ +.dark { + --background: oklch(0.247 0 129.63); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.87 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.7889 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.4202 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + + --lp-timeline-track-height: 2px; +} diff --git a/apps/www/registry/default/blocks/audio-player/components/action-controls.tsx b/apps/www/registry/default/blocks/audio-player/components/action-controls.tsx new file mode 100644 index 00000000..beb94274 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/action-controls.tsx @@ -0,0 +1,97 @@ +"use client" + +import { DotsThreeVerticalIcon } from "@phosphor-icons/react" +import { AnimatePresence, motion } from "motion/react" +import * as React from "react" + +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { Button } from "@/registry/default/blocks/audio-player/components/button" +import { + DislikeIcon, + DislikeSelectedIcon, + LikeIcon, + LikeSelectedIcon, +} from "@/registry/default/blocks/audio-player/components/icons" + +export function ActionControls() { + const [value, setValue] = React.useState("") + + const toggleGroupProps = { + onValueChange: (next: string | string[]) => { + setValue(Array.isArray(next) ? (next[0] ?? "") : next) + }, + spacing: 2, + type: "single" as const, + value, + } + + return ( +
+ )} + > + + + + + + + + + + + + + + + + + + +
+ ) +} 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 new file mode 100644 index 00000000..e8deb8ad --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/audio-source.tsx @@ -0,0 +1,191 @@ +"use client" + +import * as React from "react" + +import type { + Asset, + GetAssetId, + ResolveSource, + UseAssetOptions, +} from "@/registry/default/hooks/use-asset" + +import { PlaybackSourceController } from "@/registry/default/hooks/use-playback-source" + +export interface AudioPlayerAsset extends Asset { + description?: string + duration?: number + genre?: string + playbackUrls?: PlaybackUrls + poster?: string + title?: string +} + +export interface AudioSourceContextValue { + error: Error | null + isLoading: boolean + items: AudioPlayerAsset[] + refetch: () => Promise +} + +export interface AudioSourceProviderProps { + asset?: AudioPlayerAsset + assetOptions?: Omit< + UseAssetOptions, + "getAssetId" | "resolveSource" + > + autoLoad?: boolean + children?: React.ReactNode + getAssetId?: GetAssetId + initialIndex?: number + playlist?: AudioPlayerAsset[] + resolveSource?: ResolveSource +} + +export interface PlaybackUrls { + primary: string + secondary?: string +} + +const AudioSourceContext = React.createContext( + null +) + +interface RawPlaybackResponse { + expires_at: string + url: string +} + +export function AudioSourceProvider({ + asset, + assetOptions, + autoLoad = true, + children, + getAssetId, + initialIndex, + playlist, + resolveSource, +}: AudioSourceProviderProps) { + const items = React.useMemo(() => { + if (playlist) return playlist + if (asset) return [asset] + return [] + }, [asset, playlist]) + + const resolvedAssetOptions = React.useMemo>( + () => ({ + onLoadError: (_asset: AudioPlayerAsset, _error: unknown, { hasNext }) => { + return hasNext ? "skip" : "stop" + }, + 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 } + } + + 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) + + const { asset, signal } = context + if (asset.src) { + return { + config: asset.config, + src: asset.src, + } + } + + if (!asset.playbackUrls?.primary) { + throw new Error("AudioPlayerAsset requires src or playbackUrls.primary") + } + + const src = await fetchPlaybackUrl(asset.playbackUrls.primary, signal) + return { + config: asset.config, + src, + } + }, + [resolveSource] + ) + const refetch = React.useCallback(async () => {}, []) + + const value = React.useMemo( + () => ({ + error: null, + isLoading: false, + items, + refetch, + }), + [items, refetch] + ) + + return ( + + + {children} + + ) +} + +export function useAudioSource() { + const context = React.useContext(AudioSourceContext) + + if (!context) { + throw new Error("Missing AudioSourceProvider") + } + + return context +} + +async function fetchPlaybackUrl( + playbackUrl: string, + signal?: AbortSignal +): Promise { + const url = new URL(playbackUrl) + url.searchParams.set("raw", "true") + + const response = await fetch(url.toString(), { signal }) + if (!response.ok) { + throw new Error(`Failed to fetch playback URL: ${response.statusText}`) + } + + const data: RawPlaybackResponse = await response.json() + return data.url +} + +function isNetworkError(error: unknown) { + return ( + error && + typeof error === "object" && + "category" in error && + (error as { category: number }).category === 1 + ) +} diff --git a/apps/www/registry/default/blocks/audio-player/components/button.tsx b/apps/www/registry/default/blocks/audio-player/components/button.tsx new file mode 100644 index 00000000..d12c03bc --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/button.tsx @@ -0,0 +1,61 @@ +import { Slot } from "@radix-ui/react-slot" +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface ButtonProps extends React.ButtonHTMLAttributes { + asChild?: boolean + render?: React.ReactElement + size?: "default" | "large" | "sm" | "xl" +} + +const Button = React.forwardRef( + ({ asChild = false, className, render, size = "large", ...props }, ref) => { + const Comp = render ? Slot : asChild ? Slot : "button" + return ( + + {render + ? React.cloneElement(render, undefined, props.children) + : props.children} + + ) + } +) + +Button.displayName = "AudioPlayerButton" + +export { Button } diff --git a/apps/www/registry/default/blocks/audio-player/components/controls.tsx b/apps/www/registry/default/blocks/audio-player/components/controls.tsx new file mode 100644 index 00000000..b9f6c5e3 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/controls.tsx @@ -0,0 +1,31 @@ +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" +import { + RepeatControl, + ShuffleControl, +} from "@/registry/default/blocks/audio-player/components/playback-mode-controls" +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" + +export function PlayerControls() { + return ( +
+
+ + +
+
+ + +
+
+ + + + +
+
+ ) +} diff --git a/apps/www/registry/default/blocks/audio-player/components/fixed-timeline-control.tsx b/apps/www/registry/default/blocks/audio-player/components/fixed-timeline-control.tsx new file mode 100644 index 00000000..153e2714 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/fixed-timeline-control.tsx @@ -0,0 +1,65 @@ +"use client" + +import { cn } from "@/lib/utils" +import * as TimelineSlider from "@/registry/default/ui/timeline-control" +import { + Duration, + Elapsed, + HoverTime, +} from "@/registry/default/ui/timeline-labels" + +export function TimeLabels() { + return ( +
+ + / + +
+ ) +} + +export function TimelineControl() { + return ( +
+ + + + + + + + + + + + +
+ ) +} diff --git a/apps/www/registry/default/blocks/audio-player/components/icons.tsx b/apps/www/registry/default/blocks/audio-player/components/icons.tsx new file mode 100644 index 00000000..bbb683c8 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/icons.tsx @@ -0,0 +1,241 @@ +import type { SVGProps } from "react" + +export function DislikeIcon(props: SVGProps) { + return ( + + ) +} + +export function DislikeSelectedIcon(props: SVGProps) { + return ( + + ) +} + +export function LikeIcon(props: SVGProps) { + return ( + + ) +} + +export function LikeSelectedIcon(props: SVGProps) { + return ( + + ) +} + +export function NextIcon(props: SVGProps) { + return ( + + ) +} + +export function PauseIcon(props: SVGProps) { + return ( + + ) +} + +export function PlayIcon(props: SVGProps) { + return ( + + ) +} + +export function PreviousIcon(props: SVGProps) { + return ( + + ) +} + +export function RepeatAllIcon(props: SVGProps) { + return ( + + ) +} + +export function RepeatIcon(props: SVGProps) { + return ( + + ) +} + +export function RepeatOneIcon(props: SVGProps) { + return ( + + ) +} + +export function ShuffleOffIcon(props: SVGProps) { + return ( + + ) +} + +export function ShuffleOneIcon(props: SVGProps) { + return ( + + ) +} + +export function VolumeFullIcon(props: SVGProps) { + return ( + + ) +} + +export function VolumeMutedIcon(props: SVGProps) { + return ( + + ) +} 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 new file mode 100644 index 00000000..629f3507 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/media-player.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react" + +import type { + AudioPlayerAsset, + AudioSourceProviderProps, + PlaybackUrls, +} from "@/registry/default/blocks/audio-player/components/audio-source" + +import { cn } from "@/lib/utils" +import { AudioSourceProvider } from "@/registry/default/blocks/audio-player/components/audio-source" +import { PlayerControls } from "@/registry/default/blocks/audio-player/components/controls" +import { TimelineControl } from "@/registry/default/blocks/audio-player/components/fixed-timeline-control" +import { MediaProvider } from "@/registry/default/blocks/audio-player/lib/media-kit" +import { Media } from "@/registry/default/ui/media" + +import styles from "../audio-player.module.css" + +export type { AudioPlayerAsset, PlaybackUrls } + +export interface AudioPlayerProps { + asset?: AudioSourceProviderProps["asset"] + assetOptions?: AudioSourceProviderProps["assetOptions"] + autoLoad?: boolean + children?: ReactNode + className?: string + debug?: boolean + getAssetId?: AudioSourceProviderProps["getAssetId"] + initialIndex?: number + playlist?: AudioPlayerAsset[] + resolveSource?: AudioSourceProviderProps["resolveSource"] +} + +export function AudioPlayer({ + asset, + assetOptions, + autoLoad, + children, + className, + debug, + getAssetId, + initialIndex, + playlist, + resolveSource, +}: AudioPlayerProps = {}) { + return ( + + +
+ + + +
+ {children} +
+
+ ) +} 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 new file mode 100644 index 00000000..f466af8e --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/playback-controls.tsx @@ -0,0 +1,131 @@ +"use client" + +import { CircleNotchIcon } from "@phosphor-icons/react" +import { AnimatePresence, motion } from "motion/react" +import { useEffect, useRef, useState } from "react" + +import { Button } from "@/registry/default/blocks/audio-player/components/button" +import { + NextIcon, + PauseIcon, + PlayIcon, + PreviousIcon, +} from "@/registry/default/blocks/audio-player/components/icons" +import { usePlaybackStore } from "@/registry/default/hooks/use-playback" +import { RECOMMENDED_PLAYER_BUFFERING_THROTTLE_MS } from "@/registry/default/hooks/use-player" +import { usePlaylist } from "@/registry/default/hooks/use-playlist" +import { PlaybackControl } from "@/registry/default/ui/playback-control" + +export function PlaybackControls() { + const status = usePlaybackStore((state) => state.status) + const { hasNext, hasPrevious, next, previous } = usePlaylist() + + const rawBuffering = status === "buffering" || status === "loading" + const { isBuffering, isPlaying } = useStablePlaybackState( + rawBuffering, + status, + RECOMMENDED_PLAYER_BUFFERING_THROTTLE_MS + ) + + return ( +
+ + + + + + + +
+ ) +} + +function AnimatedIcon({ + children, + id, +}: { + children: React.ReactNode + id: string +}) { + return ( + + + {children} + + + ) +} + +function useStablePlaybackState( + isBuffering: boolean, + status: string, + delayMs: number +) { + const [showSpinner, setShowSpinner] = useState(false) + const timeoutRef = useRef>(null) + const lastIntentRef = useRef("paused") + + useEffect(() => { + if (status === "playing" || status === "paused") { + lastIntentRef.current = status + } + }, [status]) + + useEffect(() => { + if (isBuffering && lastIntentRef.current === "playing") { + timeoutRef.current = setTimeout(() => setShowSpinner(true), delayMs) + } else { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + setShowSpinner(false) + } + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + } + }, [isBuffering, delayMs]) + + // During transient buffering, hold the last intent as the visual state + const stableIsPlaying = isBuffering + ? lastIntentRef.current === "playing" + : status === "playing" + + return { isBuffering: showSpinner, isPlaying: stableIsPlaying } +} 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 new file mode 100644 index 00000000..2901e400 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/playback-mode-controls.tsx @@ -0,0 +1,90 @@ +"use client" + +import { AnimatePresence, motion } from "motion/react" + +import { cn } from "@/lib/utils" +import { Button } from "@/registry/default/blocks/audio-player/components/button" +import { + RepeatAllIcon, + RepeatIcon, + RepeatOneIcon, + ShuffleOffIcon, + ShuffleOneIcon, +} from "@/registry/default/blocks/audio-player/components/icons" +import { + usePlaylist, + usePlaylistStore, +} from "@/registry/default/hooks/use-playlist" + +export function RepeatControl() { + const { cycleRepeatMode } = usePlaylist() + const repeatMode = usePlaylistStore((state) => state.repeatMode) + const isActive = repeatMode !== "off" + + return ( + + ) +} + +export function ShuffleControl() { + const { toggleShuffle } = usePlaylist() + const shuffle = usePlaylistStore((state) => state.shuffle) + + const handleToggleShuffle = () => { + toggleShuffle() + } + + return ( + + ) +} diff --git a/apps/www/registry/default/blocks/audio-player/components/playlist.tsx b/apps/www/registry/default/blocks/audio-player/components/playlist.tsx new file mode 100644 index 00000000..fbac35a9 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/playlist.tsx @@ -0,0 +1,238 @@ +"use client" + +import { CardsThreeIcon, PlayIcon } from "@phosphor-icons/react" +import { Loader2, 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 { Button } from "@/registry/default/blocks/audio-player/components/button" +import { useAsset } from "@/registry/default/hooks/use-asset" +import { usePlaylistStore } from "@/registry/default/hooks/use-playlist" + +export function Playlist() { + const shuffle = usePlaylistStore((state) => state.shuffle) + const scrollRef = useRef(null) + + const { currentItem, isPreloaded, orderedItems, skipToId } = + useAsset() + const { error, isLoading, items, refetch } = useAudioSource() + + const displayAssets = useMemo(() => { + if (orderedItems.length > 0) + return 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]) + + const handleAssetSelect = useCallback( + (assetId: string) => { + skipToId(assetId) + }, + [skipToId] + ) + + const scrollToActive = useCallback(() => { + const container = scrollRef.current + if (!container || !currentItem) return + const activeEl = container.querySelector("[data-active='true']") + if (activeEl) { + activeEl.scrollIntoView({ behavior: "instant", block: "center" }) + } + }, [currentItem]) + + return ( + + + + +
+

+ Queue +

+
+ {shuffle && ( + + Shuffled + + )} + + {displayAssets.length} tracks + +
+
+ +
+ {isLoading && ( +
+ +
+ )} + + {error && ( +
+

+ Couldn't load queue +

+ +
+ )} + + {!isLoading && !error && displayAssets.length === 0 && ( +
+ Queue is empty +
+ )} + + {!isLoading && + !error && + displayAssets.map(({ asset, id }, index) => ( + handleAssetSelect(id)} + preloaded={isPreloaded(id)} + /> + ))} +
+
+
+ ) +} + +function formatDuration(ms?: number) { + if (typeof ms !== "number" || !Number.isFinite(ms)) return "--:--" + + const secs = Math.floor(ms / 1000) + const m = Math.floor(secs / 60) + const s = secs % 60 + return `${m}:${String(s).padStart(2, "0")}` +} + +function TrackRow({ + asset, + isActive, + onSelect, +}: { + asset: AudioPlayerAsset + index: number + isActive: boolean + onSelect: () => void + preloaded: boolean +}) { + return ( + + ) +} diff --git a/apps/www/registry/default/blocks/audio-player/components/track-info.tsx b/apps/www/registry/default/blocks/audio-player/components/track-info.tsx new file mode 100644 index 00000000..2e1335ed --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/track-info.tsx @@ -0,0 +1,53 @@ +"use client" + +import type { AudioPlayerAsset } from "@/registry/default/blocks/audio-player/components/audio-source" + +import { useAsset } from "@/registry/default/hooks/use-asset" + +export function TrackInfo() { + const { currentItem } = useAsset() + + const asset = currentItem?.properties + const title = asset?.title ?? "No track playing" + const genre = asset?.genre ?? "" + + return ( +
+
+ {asset?.poster ? ( + {`Album + ) : ( + + )} +
+
+
+ {title} +
+ {genre && ( +
+ {genre} • 2026 +
+ )} +
+
+ ) +} diff --git a/apps/www/registry/default/blocks/audio-player/components/volume-group-control.tsx b/apps/www/registry/default/blocks/audio-player/components/volume-group-control.tsx new file mode 100644 index 00000000..c1124036 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/components/volume-group-control.tsx @@ -0,0 +1,68 @@ +"use client" + +import { AnimatePresence, motion } from "motion/react" + +import { cn } from "@/lib/utils" +import { Button } from "@/registry/default/blocks/audio-player/components/button" +import { + VolumeFullIcon, + VolumeMutedIcon, +} from "@/registry/default/blocks/audio-player/components/icons" +import { useVolumeStore } from "@/registry/default/hooks/use-volume" +import { MuteControl } from "@/registry/default/ui/mute-control" +import * as VolumeSlider from "@/registry/default/ui/volume-control" + +export function VolumeControl() { + const muted = useVolumeStore((state) => state.muted) + const volume = useVolumeStore((state) => state.level) + + const isMuted = muted || volume === 0 + + return ( +
+ + + + + + + + + +
+ ) +} diff --git a/apps/www/registry/default/blocks/audio-player/hooks/use-playlist-asset.ts b/apps/www/registry/default/blocks/audio-player/hooks/use-playlist-asset.ts new file mode 100644 index 00000000..f35a9cf2 --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/hooks/use-playlist-asset.ts @@ -0,0 +1,34 @@ +"use client" + +import type { + AudioPlayerAsset, + PlaybackUrls, +} from "@/registry/default/blocks/audio-player/components/audio-source" +import type { UseAssetReturn } from "@/registry/default/hooks/use-asset" + +import { useAudioSource } from "@/registry/default/blocks/audio-player/components/audio-source" +import { useAsset } from "@/registry/default/hooks/use-asset" + +export type { PlaybackUrls } + +export type PlaylistAsset = AudioPlayerAsset + +export interface UsePlaylistAssetReturn extends UseAssetReturn { + error: Error | null + isLoading: boolean + items: PlaylistAsset[] + refetch: () => Promise +} + +export function usePlaylistAsset(): UsePlaylistAssetReturn { + const asset = useAsset() + const source = useAudioSource() + + return { + ...asset, + error: source.error, + isLoading: source.isLoading, + items: source.items, + refetch: source.refetch, + } +} diff --git a/apps/www/registry/default/blocks/audio-player/lib/media-kit.ts b/apps/www/registry/default/blocks/audio-player/lib/media-kit.ts new file mode 100644 index 00000000..1cc8c7db --- /dev/null +++ b/apps/www/registry/default/blocks/audio-player/lib/media-kit.ts @@ -0,0 +1,26 @@ +"use client" + +import { assetFeature } from "@/registry/default/hooks/use-asset" +import { captionsFeature } from "@/registry/default/hooks/use-captions" +import { mediaFeature } from "@/registry/default/hooks/use-media" +import { playbackFeature } from "@/registry/default/hooks/use-playback" +import { playerFeature } from "@/registry/default/hooks/use-player" +import { playlistFeature } from "@/registry/default/hooks/use-playlist" +import { timelineFeature } from "@/registry/default/hooks/use-timeline" +import { volumeFeature } from "@/registry/default/hooks/use-volume" +import { createMediaKit } from "@/registry/default/ui/media-provider" + +export const media = createMediaKit({ + features: [ + mediaFeature(), + playerFeature(), + playbackFeature(), + playlistFeature(), + volumeFeature(), + timelineFeature(), + captionsFeature(), + assetFeature(), + ] as const, +}) + +export const MediaProvider = media.MediaProvider diff --git a/apps/www/registry/default/blocks/basic-player/components/media-element.tsx b/apps/www/registry/default/blocks/basic-player/components/media-element.tsx deleted file mode 100644 index d2933ffd..00000000 --- a/apps/www/registry/default/blocks/basic-player/components/media-element.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import { useEffect } from "react" - -import { useMediaStore } from "@/registry/default/hooks/use-media" -import { usePlayerStore } from "@/registry/default/hooks/use-player" -import { Media } from "@/registry/default/ui/media" - -export function MediaElement({ src }: { src: string }) { - const player = usePlayerStore((state) => state.instance) - const mediaElement = useMediaStore((state) => state.mediaElement) - - useEffect(() => { - if (player && mediaElement) { - if (src) { - try { - const parsedUrl = new URL(src) - - if (!["http:", "https:"].includes(parsedUrl.protocol)) { - throw new Error("Invalid URL protocol") - } - } catch (error) { - console.error( - "Invalid playback URL:", - error instanceof Error ? error.message : "Unknown error" - ) - } - } - - void player - .load(src) - .then(() => { - console.debug("[limeplay] media loaded") - }) - .catch((error: unknown) => { - console.error("[limeplay] error loading media:", error) - }) - } - }, [player, mediaElement, src]) - - return ( - - ) -} diff --git a/apps/www/registry/default/blocks/basic-player/components/media-player.tsx b/apps/www/registry/default/blocks/basic-player/components/media-player.tsx deleted file mode 100644 index 10d7d802..00000000 --- a/apps/www/registry/default/blocks/basic-player/components/media-player.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react" - -import { cn } from "@/lib/utils" -import { MediaElement } from "@/registry/default/blocks/basic-player/components/media-element" -import { PlaybackStateControl } from "@/registry/default/blocks/basic-player/components/playback-state-control" -import { MediaProvider } from "@/registry/default/blocks/basic-player/lib/media-kit" -import { FallbackPoster } from "@/registry/default/ui/fallback-poster" -import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo" -import * as Layout from "@/registry/default/ui/player-layout" -import { RootContainer } from "@/registry/default/ui/root-container" - -export interface BasicMediaPlayerProps extends React.ComponentPropsWithoutRef<"div"> { - className?: string - debug?: boolean - src: string -} - -export const LimeplayMediaPlayer = React.forwardRef< - HTMLDivElement, - BasicMediaPlayerProps ->((props, ref) => { - const { className, src, ...etc } = props - - return ( - - - - - - - - - - - - - - - - - ) -}) - -LimeplayMediaPlayer.displayName = "LimeplayMediaPlayer" diff --git a/apps/www/registry/default/blocks/basic-player/components/playback-state-control.tsx b/apps/www/registry/default/blocks/basic-player/components/playback-state-control.tsx deleted file mode 100644 index b3b48c7c..00000000 --- a/apps/www/registry/default/blocks/basic-player/components/playback-state-control.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client" - -import { - CircleNotchIcon, - PauseIcon, - PlayIcon, - RepeatIcon, -} from "@phosphor-icons/react" - -import { Button } from "@/components/ui/button" -import { usePlaybackStore } from "@/registry/default/hooks/use-playback" -import { PlaybackControl } from "@/registry/default/ui/playback-control" - -export function PlaybackStateControl() { - const status = usePlaybackStore((state) => state.status) - - return ( - - ) -} diff --git a/apps/www/registry/default/blocks/basic-player/lib/media-kit.ts b/apps/www/registry/default/blocks/basic-player/lib/media-kit.ts deleted file mode 100644 index 4a35473c..00000000 --- a/apps/www/registry/default/blocks/basic-player/lib/media-kit.ts +++ /dev/null @@ -1,12 +0,0 @@ -"use client" - -import { mediaFeature } from "@/registry/default/hooks/use-media" -import { playbackFeature } from "@/registry/default/hooks/use-playback" -import { playerFeature } from "@/registry/default/hooks/use-player" -import { createMediaKit } from "@/registry/default/ui/media-provider" - -export const media = createMediaKit({ - features: [mediaFeature(), playerFeature(), playbackFeature()] as const, -}) - -export const MediaProvider = media.MediaProvider diff --git a/apps/www/registry/default/blocks/basic-player/page.tsx b/apps/www/registry/default/blocks/basic-player/page.tsx deleted file mode 100644 index a4a117a0..00000000 --- a/apps/www/registry/default/blocks/basic-player/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { LimeplayMediaPlayer } from "@/registry/default/blocks/basic-player/components/media-player" - -export default function Page() { - return ( -
- -
- ) -} diff --git a/apps/www/registry/default/blocks/linear-player/components/media-player.tsx b/apps/www/registry/default/blocks/linear-player/components/media-player.tsx deleted file mode 100644 index c82cb335..00000000 --- a/apps/www/registry/default/blocks/linear-player/components/media-player.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from "react" - -import type { Asset } from "@/registry/default/hooks/use-asset" - -import { cn } from "@/lib/utils" -import { BottomControls } from "@/registry/default/blocks/linear-player/components/bottom-controls" -import { MediaProvider } from "@/registry/default/blocks/linear-player/lib/media-kit" -import { CaptionsContainer } from "@/registry/default/ui/captions" -import { FallbackPoster } from "@/registry/default/ui/fallback-poster" -import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo" -import { Media, type MediaProps } from "@/registry/default/ui/media" -import * as Layout from "@/registry/default/ui/player-layout" -import { RootContainer } from "@/registry/default/ui/root-container" - -export interface LinearMediaPlayerProps< - T extends MediaType = "audio" | "video", -> { - /** - * The type of media element to render - * @default "video" - */ - as?: T - asset?: Asset - children?: React.ReactNode - className?: string - /** - * Props to pass to the underlying media element (video/audio) - */ - mediaProps?: MediaPropsForType - /** - * Ref to the underlying media element - */ - mediaRef?: React.Ref> -} - -type MediaElementType = T extends "audio" - ? HTMLAudioElement - : HTMLVideoElement - -type MediaPropsForType = T extends "audio" - ? Omit, "as" | "className"> - : Omit, "as" | "className"> - -type MediaType = MediaProps["as"] - -export const LinearMediaPlayer = React.forwardRef< - HTMLDivElement, - LinearMediaPlayerProps ->(({ as = "video", children, className, mediaProps, mediaRef }, ref) => { - return ( - - - - - - - )} - as={as} - className="size-full object-cover" - ref={mediaRef as React.Ref} - /> - - - - - - - - {children} - - ) -}) - -LinearMediaPlayer.displayName = "LinearMediaPlayer" diff --git a/apps/www/registry/default/blocks/linear-player/page.tsx b/apps/www/registry/default/blocks/linear-player/page.tsx deleted file mode 100644 index 44fff496..00000000 --- a/apps/www/registry/default/blocks/linear-player/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { LinearMediaPlayer } from "@/registry/default/blocks/linear-player/components/media-player" - -export default function Page() { - return ( -
- -
- ) -} diff --git a/apps/www/registry/default/blocks/linear-player/components/bottom-controls.tsx b/apps/www/registry/default/blocks/video-player/components/bottom-controls.tsx similarity index 56% rename from apps/www/registry/default/blocks/linear-player/components/bottom-controls.tsx rename to apps/www/registry/default/blocks/video-player/components/bottom-controls.tsx index 52113f7e..aeecb875 100644 --- a/apps/www/registry/default/blocks/linear-player/components/bottom-controls.tsx +++ b/apps/www/registry/default/blocks/video-player/components/bottom-controls.tsx @@ -1,10 +1,10 @@ -import { CaptionsStateControl } from "@/registry/default/blocks/linear-player/components/captions-state-control" -import { PictureInPictureControl } from "@/registry/default/blocks/linear-player/components/pip-control" -import { PlaybackRateControl } from "@/registry/default/blocks/linear-player/components/playback-rate-control" -import { PlaybackStateControl } from "@/registry/default/blocks/linear-player/components/playback-state-control" -import { Playlist } from "@/registry/default/blocks/linear-player/components/playlist" -import { TimelineSliderControl } from "@/registry/default/blocks/linear-player/components/timeline-slider-control" -import { VolumeGroupControl } from "@/registry/default/blocks/linear-player/components/volume-group-control" +import { CaptionsStateControl } from "@/registry/default/blocks/video-player/components/captions-state-control" +import { PictureInPictureControl } from "@/registry/default/blocks/video-player/components/pip-control" +import { PlaybackRateControl } from "@/registry/default/blocks/video-player/components/playback-rate-control" +import { PlaybackStateControl } from "@/registry/default/blocks/video-player/components/playback-state-control" +import { Playlist } from "@/registry/default/blocks/video-player/components/playlist" +import { TimelineSliderControl } from "@/registry/default/blocks/video-player/components/timeline-slider-control" +import { VolumeGroupControl } from "@/registry/default/blocks/video-player/components/volume-group-control" import * as Layout from "@/registry/default/ui/player-layout" export function BottomControls() { diff --git a/apps/www/registry/default/blocks/linear-player/components/button.tsx b/apps/www/registry/default/blocks/video-player/components/button.tsx similarity index 100% rename from apps/www/registry/default/blocks/linear-player/components/button.tsx rename to apps/www/registry/default/blocks/video-player/components/button.tsx diff --git a/apps/www/registry/default/blocks/linear-player/components/captions-state-control.tsx b/apps/www/registry/default/blocks/video-player/components/captions-state-control.tsx similarity index 87% rename from apps/www/registry/default/blocks/linear-player/components/captions-state-control.tsx rename to apps/www/registry/default/blocks/video-player/components/captions-state-control.tsx index b43af10c..e1fe35e6 100644 --- a/apps/www/registry/default/blocks/linear-player/components/captions-state-control.tsx +++ b/apps/www/registry/default/blocks/video-player/components/captions-state-control.tsx @@ -2,7 +2,7 @@ import { ClosedCaptioningIcon } from "@phosphor-icons/react" -import { Button } from "@/registry/default/blocks/linear-player/components/button" +import { Button } from "@/registry/default/blocks/video-player/components/button" import { useCaptionsStore } from "@/registry/default/hooks/use-captions" import { CaptionsControl } from "@/registry/default/ui/captions" diff --git a/apps/www/registry/default/blocks/video-player/components/media-player.tsx b/apps/www/registry/default/blocks/video-player/components/media-player.tsx new file mode 100644 index 00000000..f484c5e2 --- /dev/null +++ b/apps/www/registry/default/blocks/video-player/components/media-player.tsx @@ -0,0 +1,109 @@ +import React from "react" + +import type { + Asset, + GetAssetId, + ResolveSource, + UseAssetOptions, +} from "@/registry/default/hooks/use-asset" + +import { cn } from "@/lib/utils" +import { BottomControls } from "@/registry/default/blocks/video-player/components/bottom-controls" +import { MediaProvider } from "@/registry/default/blocks/video-player/lib/media-kit" +import { PlaybackSourceController } from "@/registry/default/hooks/use-playback-source" +import { CaptionsContainer } from "@/registry/default/ui/captions" +import { FallbackPoster } from "@/registry/default/ui/fallback-poster" +import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo" +import { Media } from "@/registry/default/ui/media" +import * as Layout from "@/registry/default/ui/player-layout" +import { RootContainer } from "@/registry/default/ui/root-container" + +export interface VideoPlayerAsset extends Asset { + description?: string + poster?: string + title?: string +} + +export interface VideoPlayerProps { + asset?: VideoPlayerAsset + assetOptions?: Omit< + UseAssetOptions, + "getAssetId" | "resolveSource" + > + children?: React.ReactNode + className?: string + debug?: boolean + getAssetId?: GetAssetId + initialIndex?: number + /** + * Props to pass to the underlying video element. + */ + mediaProps?: Omit< + React.VideoHTMLAttributes, + "as" | "className" + > + /** + * Ref to the underlying video element. + */ + mediaRef?: React.Ref + playlist?: VideoPlayerAsset[] + resolveSource?: ResolveSource +} + +export const VideoPlayer = React.forwardRef( + ( + { + asset, + assetOptions, + children, + className, + debug, + getAssetId, + initialIndex, + mediaProps, + mediaRef, + playlist, + resolveSource, + }, + ref + ) => { + const { src: mediaSrc, ...safeMediaProps } = mediaProps ?? {} + + return ( + + + + + + + + )} + as="video" + className="size-full object-cover" + ref={mediaRef as React.Ref} + /> + + + + + + + + {children} + + ) + } +) + +VideoPlayer.displayName = "VideoPlayer" diff --git a/apps/www/registry/default/blocks/linear-player/components/pip-control.tsx b/apps/www/registry/default/blocks/video-player/components/pip-control.tsx similarity index 89% rename from apps/www/registry/default/blocks/linear-player/components/pip-control.tsx rename to apps/www/registry/default/blocks/video-player/components/pip-control.tsx index 7fd5d8ff..c8202d42 100644 --- a/apps/www/registry/default/blocks/linear-player/components/pip-control.tsx +++ b/apps/www/registry/default/blocks/video-player/components/pip-control.tsx @@ -2,7 +2,7 @@ import { PictureInPictureIcon } from "@phosphor-icons/react" -import { Button } from "@/registry/default/blocks/linear-player/components/button" +import { Button } from "@/registry/default/blocks/video-player/components/button" import { usePictureInPictureStore } from "@/registry/default/hooks/use-picture-in-picture" import { PictureInPictureControl as PictureInPictureControlPrimitive } from "@/registry/default/ui/picture-in-picture-control" diff --git a/apps/www/registry/default/blocks/linear-player/components/playback-rate-control.tsx b/apps/www/registry/default/blocks/video-player/components/playback-rate-control.tsx similarity index 100% rename from apps/www/registry/default/blocks/linear-player/components/playback-rate-control.tsx rename to apps/www/registry/default/blocks/video-player/components/playback-rate-control.tsx diff --git a/apps/www/registry/default/blocks/linear-player/components/playback-state-control.tsx b/apps/www/registry/default/blocks/video-player/components/playback-state-control.tsx similarity index 91% rename from apps/www/registry/default/blocks/linear-player/components/playback-state-control.tsx rename to apps/www/registry/default/blocks/video-player/components/playback-state-control.tsx index 8eb7073f..d46ee2ba 100644 --- a/apps/www/registry/default/blocks/linear-player/components/playback-state-control.tsx +++ b/apps/www/registry/default/blocks/video-player/components/playback-state-control.tsx @@ -7,7 +7,7 @@ import { RepeatIcon, } from "@phosphor-icons/react" -import { Button } from "@/registry/default/blocks/linear-player/components/button" +import { Button } from "@/registry/default/blocks/video-player/components/button" import { usePlaybackStore } from "@/registry/default/hooks/use-playback" import { PlaybackControl } from "@/registry/default/ui/playback-control" diff --git a/apps/www/registry/default/blocks/linear-player/components/playlist.tsx b/apps/www/registry/default/blocks/video-player/components/playlist.tsx similarity index 52% rename from apps/www/registry/default/blocks/linear-player/components/playlist.tsx rename to apps/www/registry/default/blocks/video-player/components/playlist.tsx index 6a01099d..704d4edb 100644 --- a/apps/www/registry/default/blocks/linear-player/components/playlist.tsx +++ b/apps/www/registry/default/blocks/video-player/components/playlist.tsx @@ -1,11 +1,8 @@ "use client" -import type shaka from "shaka-player" - import { CardsThreeIcon, PlayIcon } from "@phosphor-icons/react" -import { useEffect } from "react" -import type { Asset } from "@/registry/default/hooks/use-asset" +import type { VideoPlayerAsset } from "@/registry/default/blocks/video-player/components/media-player" import { DropdownMenu, @@ -16,77 +13,19 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Button } from "@/registry/default/blocks/linear-player/components/button" +import { Button } from "@/registry/default/blocks/video-player/components/button" import { useAsset } from "@/registry/default/hooks/use-asset" -import { usePlayerStore } from "@/registry/default/hooks/use-player" - -/** - * Demo assets for the linear-player - */ -export const ASSETS: Asset[] = [ - { - config: { - drm: { - advanced: { - "com.widevine.alpha": { - serverCertificateUri: - "https://cwip-shaka-proxy.appspot.com/service-cert", - }, - }, - servers: { - "com.widevine.alpha": "https://cwip-shaka-proxy.appspot.com/no_auth", - }, - } as unknown as shaka.extern.DrmConfiguration, - } as shaka.extern.PlayerConfiguration, - description: - "A Blender Foundation short film, protected by Widevine encryption", - id: "sintel", - poster: "https://storage.googleapis.com/shaka-asset-icons/sintel.png", - src: "https://storage.googleapis.com/shaka-demo-assets/sintel-widevine/dash.mpd", - title: "Blender Foundation - Sintel", - }, - { - description: "Media Tailor HLS Stream", - id: "sing2", - poster: "https://storage.googleapis.com/shaka-asset-icons/sing.png", - src: "https://ad391cc0d55b44c6a86d232548adc225.mediatailor.us-east-1.amazonaws.com/v1/master/d02fedbbc5a68596164208dd24e9b48aa60dadc7/singssai/master.m3u8", - title: "Sing 2 Trailer", - }, - { - description: "A Blender Foundation short film, Media Tailor Live DASH", - id: "bbb", - poster: - "https://storage.googleapis.com/shaka-asset-icons/big_buck_bunny.png", - src: "https://d305rncpy6ne2q.cloudfront.net/v1/dash/94063eadf7d8c56e9e2edd84fdf897826a70d0df/SFP-MediaTailor-Live-HLS-DASH/channel/sfp-channel1/dash.mpd", - title: "Big Buck Bunny", - }, - { - description: "HLS Video", - id: "natgeo", - poster: "https://demo.theoplayer.com/hubfs/videos/natgeo/poster.jpg", - src: "https://demo.theoplayer.com/hubfs/videos/natgeo/playlist.m3u8", - title: "National Geographic - VR equirectangular", - }, -] export function Playlist() { - const player = usePlayerStore((state) => state.instance) - - const { currentItem, isPreloaded, loadPlaylist, preloadAsset, skipToId } = - useAsset() - - useEffect(() => { - if (!player) return - - loadPlaylist(ASSETS) - }, [player]) + const { currentItem, isPreloaded, orderedItems, preloadAsset, skipToId } = + useAsset() - const handleAssetSelect = async (asset: Asset) => { - await skipToId(asset.id) + const handleAssetSelect = async (assetId: string) => { + await skipToId(assetId) } - const handleAssetHover = async (asset: Asset) => { - if (!isPreloaded(asset.id) && currentItem?.id !== asset.id) { + const handleAssetHover = async (assetId: string, asset: VideoPlayerAsset) => { + if (!isPreloaded(assetId) && currentItem?.id !== assetId) { await preloadAsset(asset) } } @@ -109,9 +48,10 @@ export function Playlist() { Playlist
- {ASSETS.map((asset) => { - const isCurrentAsset = currentItem?.id === asset.id - const isAssetPreloaded = isPreloaded(asset.id) + {orderedItems.map((item) => { + const asset = item.properties + const isCurrentAsset = currentItem?.id === item.id + const isAssetPreloaded = isPreloaded(item.id) return ( handleAssetSelect(asset)} - onMouseEnter={() => handleAssetHover(asset)} + key={item.id} + onClick={() => handleAssetSelect(item.id)} + onMouseEnter={() => handleAssetHover(item.id, asset)} >
{asset.title} + + + ) +} diff --git a/apps/www/registry/default/blocks/linear-player/ui/toggle.tsx b/apps/www/registry/default/blocks/video-player/ui/toggle.tsx similarity index 100% rename from apps/www/registry/default/blocks/linear-player/ui/toggle.tsx rename to apps/www/registry/default/blocks/video-player/ui/toggle.tsx diff --git a/apps/www/registry/default/examples/picture-in-picture-control-demo.tsx b/apps/www/registry/default/examples/picture-in-picture-control-demo.tsx index e83d851a..43624c07 100644 --- a/apps/www/registry/default/examples/picture-in-picture-control-demo.tsx +++ b/apps/www/registry/default/examples/picture-in-picture-control-demo.tsx @@ -2,7 +2,7 @@ import { PictureInPictureIcon } from "@phosphor-icons/react" -import { Button } from "@/registry/default/blocks/linear-player/components/button" +import { Button } from "@/registry/default/blocks/video-player/components/button" import { usePictureInPictureStore } from "@/registry/default/hooks/use-picture-in-picture" import { PictureInPictureControl } from "@/registry/default/ui/picture-in-picture-control" diff --git a/apps/www/registry/default/hooks/use-asset.ts b/apps/www/registry/default/hooks/use-asset.ts index 2c7e3528..97a6ffb1 100644 --- a/apps/www/registry/default/hooks/use-asset.ts +++ b/apps/www/registry/default/hooks/use-asset.ts @@ -2,7 +2,7 @@ import type shaka from "shaka-player" -import { useCallback, useEffect } from "react" +import { useCallback, useEffect, useId } from "react" import type { PlaybackEvents, @@ -11,7 +11,6 @@ import type { import type { PlayerEvents, PlayerStore, - UsePlayerOptions, } from "@/registry/default/hooks/use-player" import type { PlaylistChangeEvent, @@ -35,38 +34,88 @@ import { export interface Asset { config?: shaka.extern.PlayerConfiguration description?: string - id: string + id?: string poster?: string - src: string + src?: string title?: string } -export interface UseAssetLoadContext { +export interface AssetLoadContext { asset: TAsset - defaultLoad: ( - source?: null | shaka.media.PreloadManager | string, - startTime?: number + loadDefault: ( + source?: null | PlaybackSource | shaka.media.PreloadManager | string, + options?: number | { startTime?: number } ) => Promise media: HTMLMediaElement player: shaka.Player preloadManager?: shaka.media.PreloadManager + previousError?: unknown + retryCount: number signal: AbortSignal startTime?: number } +export interface AssetPreloadContext { + asset: TAsset + player: shaka.Player + preloadDefault: ( + source?: PlaybackSource | string + ) => Promise + previousError?: unknown + retryCount: number + signal: AbortSignal +} + +export type AssetSourceOperation = "load" | "preload" + +export type GetAssetId = ( + asset: TAsset, + context: { + index?: number + origin: "asset" | "media-props" | "playlist" + } +) => null | string | undefined + +export interface NormalizedAsset { + id: string + properties: TAsset +} + +export interface PlaybackSource { + config?: shaka.extern.PlayerConfiguration + src?: string +} + +export type ResolveSource = ( + context: ResolveSourceContext +) => MaybePromise + +export interface ResolveSourceContext { + asset: TAsset + operation: AssetSourceOperation + previousError?: unknown + retryCount: number + signal: AbortSignal + startTime?: number +} + +export type UseAssetLoadContext = AssetLoadContext + export interface UseAssetLoader { - load?: (context: UseAssetLoadContext) => Promise + load?: (context: AssetLoadContext) => Promise preload?: ( - context: UseAssetPreloadContext + context: AssetPreloadContext ) => Promise } export interface UseAssetOptions { autoplayFirst?: boolean + getAssetId?: GetAssetId loader?: UseAssetLoader maxRetries?: number onAssetChange?: (event: PlaylistChangeEvent) => void onAssetLoaded?: (asset: TAsset) => void + onError?: (error: Error, asset?: TAsset) => void onLoadError?: ( asset: TAsset, error: unknown, @@ -81,16 +130,11 @@ export interface UseAssetOptions { | { action: "skip" } | { action: "stop" } > - playerOptions?: Partial> + resolveSource?: ResolveSource } -export interface UseAssetPreloadContext { - asset: TAsset - defaultPreload: ( - source?: string - ) => Promise - player: shaka.Player -} +export type UseAssetPreloadContext = + AssetPreloadContext export interface UseAssetReturn { cancelPreload: (assetId: string) => void @@ -135,21 +179,31 @@ export const ASSET_FEATURE_KEY = "asset" export interface AssetStore { [ASSET_FEATURE_KEY]: { + clearOptions: (ownerId: string) => void installedOptions?: UseAssetOptions isFirstLoad: boolean loadAbortController: AbortController | null loadAsset: (asset: Asset, startTime?: number) => Promise loadGeneration: number loadPlaylist: (assets: Asset[], startIndex?: number) => void + optionsOwnerId: null | string preloadAsset: (asset: Asset) => Promise preloadNext: () => Promise + previousError: unknown retryCount: number - setOptions: (options?: UseAssetOptions) => void + setOptions: (options: UseAssetOptions, ownerId: string) => void } } type AssetSetupStore = AssetStore & PlaybackStore & PlayerStore & PlaylistStore +type MaybePromise = Promise | T + +type NormalizedSource = { + config?: shaka.extern.PlayerConfiguration + source?: null | shaka.media.PreloadManager | string +} + export function assetFeature(): MediaFeature< AssetStore, AssetStore & MediaStore & PlaybackStore & PlayerStore & PlaylistStore @@ -157,6 +211,14 @@ export function assetFeature(): MediaFeature< return { createSlice: (set, get) => ({ [ASSET_FEATURE_KEY]: { + clearOptions: (ownerId) => { + if (get().asset.optionsOwnerId !== ownerId) return + + set(({ asset }) => { + asset.installedOptions = undefined + asset.optionsOwnerId = null + }) + }, installedOptions: undefined, isFirstLoad: true, loadAbortController: null, @@ -171,6 +233,10 @@ export function assetFeature(): MediaFeature< return false } + const assetId = getNormalizedAssetId(asset, options, { + origin: "asset", + }) + const generation = get().asset.loadGeneration + 1 get().asset.loadAbortController?.abort() @@ -183,60 +249,89 @@ export function assetFeature(): MediaFeature< media.pause() - const { - onError: onPlayerError, - onLoad, - onPreload: _onPreload, - } = options?.playerOptions ?? {} - try { const preloadManagers = get().player.preloadManagers as Map< string, shaka.media.PreloadManager > - const preloadManager = preloadManagers.get(asset.id) - - const defaultLoad = async ( - source?: null | shaka.media.PreloadManager | string, - customStartTime?: number + const preloadManager = preloadManagers.get(assetId) + const retryCount = get().asset.retryCount + const previousError = get().asset.previousError ?? undefined + + const loadDefault = async ( + source?: + | null + | PlaybackSource + | shaka.media.PreloadManager + | string, + loadOptions?: number | { startTime?: number } ) => { + const normalizedSource = normalizePlaybackSource(source) player.resetConfiguration() + if (asset.config) { player.configure(asset.config) } - const resolvedSource = - source === undefined ? (preloadManager ?? asset.src) : source + if (normalizedSource.config) { + player.configure(normalizedSource.config) + } - const finalStartTime = customStartTime ?? startTime + const shouldUseFallbackSource = + source === undefined || + source === null || + (Boolean(normalizedSource.config) && !normalizedSource.source) + const resolvedSource = shouldUseFallbackSource + ? (preloadManager ?? normalizedSource.source ?? asset.src) + : normalizedSource.source + + const finalStartTime = + typeof loadOptions === "number" + ? loadOptions + : (loadOptions?.startTime ?? startTime) const timeToLoad = finalStartTime ?? undefined - if (resolvedSource) { - await player.load(resolvedSource, timeToLoad) - } else { - await player.load(asset.src, timeToLoad) + if (!resolvedSource) { + throw new Error( + `[useAsset] Missing playback source for asset "${assetId}". Provide asset.src or resolveSource.` + ) } + + await player.load(resolvedSource, timeToLoad) } if (options?.loader?.load) { await options.loader.load({ asset, - defaultLoad, + loadDefault, media, player, preloadManager, + previousError, + retryCount, signal: abortController.signal, startTime, }) - } else if (onLoad) { - await onLoad(asset, player, media, preloadManager, startTime) } else { - await defaultLoad() + const resolved = await resolveSourceValue(options, { + asset, + operation: "load", + previousError, + retryCount, + signal: abortController.signal, + startTime, + }) + + if (abortController.signal.aborted) { + return false + } + + await loadDefault(resolved) } if (preloadManager) { const updated = new Map(preloadManagers) - updated.delete(asset.id) + updated.delete(assetId) set(({ player }) => { player.preloadManagers = updated }) @@ -258,6 +353,7 @@ export function assetFeature(): MediaFeature< set(({ asset }) => { asset.isFirstLoad = false + asset.previousError = null asset.retryCount = 0 }) @@ -272,17 +368,17 @@ export function assetFeature(): MediaFeature< return false } - onPlayerError?.( - error instanceof Error - ? error - : Object.assign(new Error(String(error)), error as object), - asset - ) + const normalizedError = toError(error) + options?.onError?.(normalizedError, asset) const playlist = get().playlist const maxRetries = options?.maxRetries ?? 0 const currentRetryCount = get().asset.retryCount const hasNext = playlist.getNextIndex() !== -1 + set(({ asset }) => { + asset.previousError = error + }) + if (options?.onLoadError) { const decision = options.onLoadError(asset, error, { hasNext, @@ -302,6 +398,7 @@ export function assetFeature(): MediaFeature< hasNext ) { set(({ asset }) => { + asset.previousError = null asset.retryCount = 0 }) playlist.next() @@ -309,6 +406,7 @@ export function assetFeature(): MediaFeature< } set(({ asset }) => { + asset.previousError = null asset.retryCount = 0 }) return false @@ -316,6 +414,7 @@ export function assetFeature(): MediaFeature< if (hasNext) { set(({ asset }) => { + asset.previousError = null asset.retryCount = 0 }) playlist.next() @@ -325,18 +424,20 @@ export function assetFeature(): MediaFeature< }, loadGeneration: 0, loadPlaylist: (assets, startIndex = 0) => { + const options = get().asset.installedOptions as + | undefined + | UseAssetOptions + const normalizedAssets = normalizeAssets(assets, options) + set(({ asset }) => { asset.isFirstLoad = true + asset.previousError = null + asset.retryCount = 0 }) - get().playlist.load( - assets.map((asset) => ({ - id: asset.id, - properties: asset, - })), - startIndex - ) + get().playlist.load(normalizedAssets, startIndex) }, + optionsOwnerId: null, preloadAsset: async (asset) => { const player = get().player.instance as null | shaka.Player const options = get().asset.installedOptions as @@ -344,19 +445,32 @@ export function assetFeature(): MediaFeature< | UseAssetOptions if (!player) return - const { onError: onPlayerError, onPreload } = - options?.playerOptions ?? {} + const assetId = getNormalizedAssetId(asset, options, { + origin: "asset", + }) try { - const defaultPreload = async ( - source?: string + const retryCount = get().asset.retryCount + const previousError = get().asset.previousError ?? undefined + const abortController = new AbortController() + + const preloadDefault = async ( + source?: PlaybackSource | string ): Promise => { - const resolvedSource = source ?? asset.src + const normalizedSource = normalizePlaybackSource(source) + const resolvedSource = normalizedSource.source ?? asset.src + + if (!resolvedSource || typeof resolvedSource !== "string") { + throw new Error( + `[useAsset] Missing preload source for asset "${assetId}". Provide asset.src or resolveSource.` + ) + } + return player.preload( resolvedSource, undefined, undefined, - asset.config + mergePlayerConfiguration(asset.config, normalizedSource.config) ) } @@ -365,13 +479,22 @@ export function assetFeature(): MediaFeature< if (options?.loader?.preload) { manager = await options.loader.preload({ asset, - defaultPreload, player, + preloadDefault, + previousError, + retryCount, + signal: abortController.signal, }) - } else if (onPreload) { - manager = await onPreload(asset, player) } else { - manager = await defaultPreload() + const resolved = await resolveSourceValue(options, { + asset, + operation: "preload", + previousError, + retryCount, + signal: abortController.signal, + }) + + manager = await preloadDefault(resolved ?? undefined) } if (manager) { @@ -379,25 +502,21 @@ export function assetFeature(): MediaFeature< string, shaka.media.PreloadManager > + const previousManager = existing.get(assetId) + + if (previousManager && previousManager !== manager) { + void previousManager.destroy() + } + const updated = new Map(existing) - updated.set(asset.id, manager) + updated.set(assetId, manager) set(({ player }) => { player.preloadManagers = updated }) } } catch (error) { - const err = - error instanceof Error - ? error - : typeof error === "object" && error !== null - ? Object.assign( - new Error( - String((error as { message?: string }).message ?? error) - ), - error - ) - : new Error(String(error)) - onPlayerError?.(err, asset) + const err = toError(error) + options?.onError?.(err, asset) console.error("[useAsset] Preload error:", error) } }, @@ -413,10 +532,12 @@ export function assetFeature(): MediaFeature< ) } }, + previousError: null, retryCount: 0, - setOptions: (options) => { + setOptions: (options, ownerId) => { set(({ asset }) => { asset.installedOptions = options + asset.optionsOwnerId = ownerId }) }, }, @@ -425,11 +546,11 @@ export function assetFeature(): MediaFeature< Setup: AssetSetup, } } - export function useAsset( options?: UseAssetOptions ): UseAssetReturn { const api = useMediaFeatureApi(ASSET_FEATURE_KEY) + const optionsOwnerId = useId() const playlist = usePlaylist() const loadAsset = useAssetStore((state) => state.loadAsset) as ( asset: TAsset, @@ -447,10 +568,17 @@ export function useAsset( useEffect(() => { if (!options) return - api.setState(({ asset }) => { - asset.installedOptions = options as unknown as UseAssetOptions - }) - }, [api, options]) + api + .getState() + .asset.setOptions( + options as unknown as UseAssetOptions, + optionsOwnerId + ) + + return () => { + api.getState().asset.clearOptions(optionsOwnerId) + } + }, [api, options, optionsOwnerId]) const cancelPreload = useCallback( (assetId: string) => { @@ -547,6 +675,9 @@ function AssetSetup() { const assetToLoad = (decision.asset ?? currentItem.properties) as unknown as Asset const startTime = decision.startTime ?? currentTime + api.setState(({ asset }) => { + asset.previousError = payload.error + }) void api.getState().asset.loadAsset(assetToLoad, startTime) } else if (decision.action === "skip" && getHasNext()) { api.getState().playlist.next() @@ -575,3 +706,114 @@ function AssetSetup() { return null } + +function getNormalizedAssetId( + asset: Asset, + options: undefined | UseAssetOptions, + context: { index?: number; origin: "asset" | "media-props" | "playlist" } +): string { + const id = asset.id ?? options?.getAssetId?.(asset, context) + if (id) return id + + if (asset.src) { + return `lp_src_${hashString(asset.src)}` + } + + throw new Error( + `[useAsset] Missing asset id. Provide asset.id, asset.src, or getAssetId for ${context.origin} assets.` + ) +} + +function hashString(value: string): string { + let hash = 0 + + for (let index = 0; index < value.length; index++) { + hash = (hash << 5) - hash + value.charCodeAt(index) + hash |= 0 + } + + return Math.abs(hash).toString(36) +} + +function mergePlayerConfiguration( + base?: shaka.extern.PlayerConfiguration, + override?: shaka.extern.PlayerConfiguration +): shaka.extern.PlayerConfiguration | undefined { + if (!base) return override + if (!override) return base + + return { + ...base, + ...override, + } +} + +function normalizeAssets( + assets: Asset[], + options?: UseAssetOptions +): NormalizedAsset[] { + const usedIds = new Set() + + return assets.map((asset, index) => { + const id = getNormalizedAssetId(asset, options, { + index, + origin: "playlist", + }) + + if (usedIds.has(id)) { + throw new Error( + `[useAsset] Duplicate asset id "${id}". Provide explicit ids for duplicate playlist entries.` + ) + } + + usedIds.add(id) + + return { + id, + properties: asset, + } + }) +} + +function normalizePlaybackSource( + source?: null | PlaybackSource | shaka.media.PreloadManager | string +): NormalizedSource { + if (typeof source === "string") { + return { source } + } + + if (!source) { + return {} + } + + if ("src" in source || "config" in source) { + const playbackSource = source as PlaybackSource + + return { + config: playbackSource.config, + source: playbackSource.src, + } + } + + return { source: source as shaka.media.PreloadManager } +} + +async function resolveSourceValue( + options: undefined | UseAssetOptions, + context: ResolveSourceContext +): Promise { + return options?.resolveSource?.(context) +} + +function toError(error: unknown): Error { + if (error instanceof Error) return error + + if (typeof error === "object" && error !== null) { + return Object.assign( + new Error(String((error as { message?: string }).message ?? error)), + error + ) + } + + return new Error(String(error)) +} diff --git a/apps/www/registry/default/hooks/use-media.ts b/apps/www/registry/default/hooks/use-media.ts index 8636c9c4..99f2c756 100644 --- a/apps/www/registry/default/hooks/use-media.ts +++ b/apps/www/registry/default/hooks/use-media.ts @@ -6,8 +6,9 @@ import { useMediaFeatureStore } from "@/registry/default/ui/media-provider" export const MEDIA_FEATURE_KEY = "media" -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface MediaProviderProps {} +export interface MediaProviderProps { + debug?: boolean +} export interface MediaStore { media: { diff --git a/apps/www/registry/default/hooks/use-playback-source.ts b/apps/www/registry/default/hooks/use-playback-source.ts new file mode 100644 index 00000000..69f631e8 --- /dev/null +++ b/apps/www/registry/default/hooks/use-playback-source.ts @@ -0,0 +1,96 @@ +"use client" + +import { useEffect, useMemo, useRef } from "react" + +import type { + Asset, + GetAssetId, + ResolveSource, + UseAssetOptions, +} from "@/registry/default/hooks/use-asset" + +import { useAsset } from "@/registry/default/hooks/use-asset" +import { usePlayerStore } from "@/registry/default/hooks/use-player" + +export interface UsePlaybackSourceOptions { + asset?: TAsset + assetOptions?: Omit, "getAssetId" | "resolveSource"> + autoLoad?: boolean + getAssetId?: GetAssetId + initialIndex?: number + mediaSrc?: string + playlist?: TAsset[] + resolveSource?: ResolveSource + sourceKey?: string +} + +export function PlaybackSourceController( + props: UsePlaybackSourceOptions +) { + usePlaybackSource(props) + + return null +} + +export function usePlaybackSource( + options: UsePlaybackSourceOptions +): void { + const { + asset, + assetOptions, + autoLoad = true, + getAssetId, + initialIndex = 0, + mediaSrc, + playlist, + resolveSource, + sourceKey, + } = options + + const player = usePlayerStore((state) => state.instance) + const installedOptions = useMemo>( + () => ({ + ...assetOptions, + getAssetId, + resolveSource, + }), + [assetOptions, getAssetId, resolveSource] + ) + const { loadPlaylist } = useAsset(installedOptions) + const loadedSourceKeyRef = useRef(null) + + const assets = useMemo(() => { + if (playlist) return playlist + if (asset) return [asset] + if (mediaSrc) return [{ src: mediaSrc } as TAsset] + + return [] + }, [asset, mediaSrc, playlist]) + + const resolvedSourceKey = useMemo(() => { + if (sourceKey) return sourceKey + + return assets + .map((item, index) => { + const id = + item.id ?? + getAssetId?.(item, { + index, + origin: playlist ? "playlist" : asset ? "asset" : "media-props", + }) ?? + item.src + + return id ?? `item:${index}` + }) + .join("|") + }, [asset, assets, getAssetId, playlist, sourceKey]) + + useEffect(() => { + if (!autoLoad || !player || assets.length === 0) return + + if (loadedSourceKeyRef.current === resolvedSourceKey) return + loadedSourceKeyRef.current = resolvedSourceKey + + loadPlaylist(assets, initialIndex) + }, [assets, autoLoad, initialIndex, loadPlaylist, player, resolvedSourceKey]) +} diff --git a/apps/www/registry/default/hooks/use-player.ts b/apps/www/registry/default/hooks/use-player.ts index d16a14d5..1646bc70 100644 --- a/apps/www/registry/default/hooks/use-player.ts +++ b/apps/www/registry/default/hooks/use-player.ts @@ -261,7 +261,8 @@ function PlayerSetup() { const localPlayer = new shakaLib.Player() as shaka.Player try { - await localPlayer.attach(mediaElement) + // DEV: initializeMediaSource is false, otherwise player is in loaded status by default + await localPlayer.attach(mediaElement, false) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- mutated by cleanup closure during await if (aborted) { diff --git a/apps/www/registry/default/hooks/use-playlist.ts b/apps/www/registry/default/hooks/use-playlist.ts index 4379f918..7eecbfad 100644 --- a/apps/www/registry/default/hooks/use-playlist.ts +++ b/apps/www/registry/default/hooks/use-playlist.ts @@ -254,8 +254,13 @@ export function playlistFeature(): MediaFeature { load: (items, startIndex = 0) => { const playlist = get().playlist as PlaylistStore["playlist"] const normalized = normalizeItems(items) + const normalizedStartIndex = clamp( + startIndex, + 0, + Math.max(normalized.length - 1, 0) + ) const shuffleOrder = playlist.shuffle - ? createShuffleOrder(normalized.length, startIndex) + ? createShuffleOrder(normalized.length, normalizedStartIndex) : [] set(({ playlist }) => { @@ -267,17 +272,17 @@ export function playlistFeature(): MediaFeature { playlist.shuffleOrder = shuffleOrder }) - if (normalized.length > 0 && startIndex < normalized.length) { - const startItem = normalized[startIndex] as PlaylistItem + if (normalized.length > 0) { + const startItem = normalized[normalizedStartIndex] as PlaylistItem set(({ playlist }) => { - playlist.currentIndex = startIndex + playlist.currentIndex = normalizedStartIndex playlist.currentItem = startItem }) emitPlaylistChange( events.emit, - startIndex, + normalizedStartIndex, startItem, -1, null, diff --git a/apps/www/registry/default/ui/limeplay-logo.tsx b/apps/www/registry/default/ui/limeplay-logo.tsx index 43502bf3..4e03fc33 100644 --- a/apps/www/registry/default/ui/limeplay-logo.tsx +++ b/apps/www/registry/default/ui/limeplay-logo.tsx @@ -5,15 +5,7 @@ export function LimeplayLogo(props: React.SVGProps) { return ( ) { const runtimeRef = React.useRef store: ImmerStoreApi @@ -291,10 +294,19 @@ export function createMediaKit< if (!runtimeRef.current) { runtimeRef.current = createMediaStore() + if (typeof debug === "boolean") { + setMediaDebug(runtimeRef.current.store, debug) + } } const runtime = runtimeRef.current + React.useLayoutEffect(() => { + if (typeof debug !== "boolean") return + + setMediaDebug(runtime.store, debug) + }, [debug, runtime.store]) + return ( ) { } } +function setMediaDebug(store: ImmerStoreApi, debug: boolean) { + const state = store.getState() + + if (!("media" in state) || typeof state.media !== "object") { + return + } + + store.setState((state) => { + if ("media" in state && typeof state.media === "object") { + ;(state.media as MediaStore["media"]).debug = debug + } + }) +} + function useMediaRuntime() { const context = React.useContext(MediaRuntimeContext) diff --git a/apps/www/registry/default/ui/media.tsx b/apps/www/registry/default/ui/media.tsx index 132440a4..f1aeaf35 100644 --- a/apps/www/registry/default/ui/media.tsx +++ b/apps/www/registry/default/ui/media.tsx @@ -14,7 +14,7 @@ export type MediaProps = * * @default video */ - as: "video" + as?: "video" }) export type MediaPropsDocs = Pick diff --git a/apps/www/registry/pro b/apps/www/registry/pro index 7c47ec19..53d1af0f 160000 --- a/apps/www/registry/pro +++ b/apps/www/registry/pro @@ -1 +1 @@ -Subproject commit 7c47ec191091bcaa5a02df8f2eab484f0f85e1c6 +Subproject commit 53d1af0f2fe113749cda35ca9133ae0ee5e68f8e diff --git a/apps/www/scripts/test-registry-install.ts b/apps/www/scripts/test-registry-install.ts index 0dc7a7e3..4ced27c2 100644 --- a/apps/www/scripts/test-registry-install.ts +++ b/apps/www/scripts/test-registry-install.ts @@ -48,21 +48,15 @@ interface BlockConfig { } const BLOCK_CONFIGS: Record = { - "basic-player": { - component: "LimeplayMediaPlayer", - importPath: "components/basic-player/components/media-player", - props: `src="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"`, - url: "/r/basic-player.json", + "audio-player": { + component: "AudioPlayer", + importPath: "components/audio-player/components/media-player", + url: "/r/audio-player.json", }, - "linear-player": { - component: "LinearMediaPlayer", - importPath: "components/linear-player/components/media-player", - url: "/r/linear-player.json", - }, - "youtube-music": { - component: "YouTubeMusicPlayer", - importPath: "components/youtube-music/components/media-player", - url: "/r/pro/youtube-music.json", + "video-player": { + component: "VideoPlayer", + importPath: "components/video-player/components/media-player", + url: "/r/video-player.json", }, } @@ -184,7 +178,11 @@ async function buildApp(app: PreparedApp): Promise { function buildRegistry() { console.log("\nšŸ“¦ Building registry...") - const result = runSync("bun run registry:build", WWW_DIR, "registry:build") + const result = runSync( + `REGISTRY_HOST=http://localhost:${SERVER_PORT} bun run ./scripts/registry-dev.mts`, + WWW_DIR, + "registry:build" + ) if (!result.ok) { console.log("āŒ Registry build failed") process.exit(1) diff --git a/apps/www/scripts/validate-registries.ts b/apps/www/scripts/validate-registries.ts index bd287d1a..8d1d998e 100644 --- a/apps/www/scripts/validate-registries.ts +++ b/apps/www/scripts/validate-registries.ts @@ -147,7 +147,7 @@ async function validateBuiltRegistryJson() { const SHADCN_REGISTRY_URL = "https://ui.shadcn.com/r/styles/default" -/** Extract common block path prefix from the first file entry, e.g. "blocks/youtube-music" */ +/** Extract common block path prefix from the first file entry, e.g. "blocks/audio-player" */ function getBlockPrefix(files: Array<{ path: string }>): null | string { const first = files[0]?.path if (!first) return null diff --git a/apps/www/vercel.json b/apps/www/vercel.json index aa12dff2..cd20dcdb 100644 --- a/apps/www/vercel.json +++ b/apps/www/vercel.json @@ -6,6 +6,46 @@ "destination": "/docs/quick-start", "permanent": true }, + { + "source": "/blocks/linear-player", + "destination": "/blocks/video-player", + "permanent": true + }, + { + "source": "/blocks/youtube-music", + "destination": "/blocks/audio-player", + "permanent": true + }, + { + "source": "/docs/blocks/linear-player", + "destination": "/blocks/video-player", + "permanent": true + }, + { + "source": "/docs/blocks/youtube-music", + "destination": "/blocks/audio-player", + "permanent": true + }, + { + "source": "/r/linear-player.json", + "destination": "/r/video-player.json", + "permanent": true + }, + { + "source": "/r/basic-player.json", + "destination": "/r/video-player.json", + "permanent": true + }, + { + "source": "/r/pro/youtube-music.json", + "destination": "/r/audio-player.json", + "permanent": true + }, + { + "source": "/r/pro/audio-player.json", + "destination": "/r/audio-player.json", + "permanent": true + }, { "source": "/r/pro/:path*", "destination": "/403", diff --git a/prompts/ide.md b/prompts/ide.md index c9b7d825..71423c39 100644 --- a/prompts/ide.md +++ b/prompts/ide.md @@ -21,7 +21,7 @@ The codebase is structured around: * Composed blocks (`registry/default/blocks`) Inside `apps/www/registry/default/blocks`, final reference implementations exist. -`linear-player` is the **primary source of truth** and should always be used as the baseline example. +`video-player` is the **primary source of truth** and should always be used as the baseline example. We are using Tailwind CSS v4 for styling, Zustand + Immer for state management, shadcn/ui (w/ Radix or BaseUI) for primitive components, React v19 and Next.js v16 for framework. Limeplay uses shaka-player for player engine. @@ -160,13 +160,13 @@ When `shadcn add` installs a registry item, it rewrites import paths by matching **Rule:** Block-internal files MUST have unique names that don't collide with any registry item name in the dependency tree. -* āŒ `blocks/linear-player/lib/media.ts` — "media" is a limeplay registry item (registryDependency) → import gets hijacked to its target -* āŒ `blocks/linear-player/ui/button.tsx` — "button" is a shadcn built-in registry item → import gets hijacked -* āŒ `blocks/linear-player/components/picture-in-picture-control.tsx` — same name as limeplay primitive → hijacked -* āœ… `blocks/linear-player/components/media-kit.ts` — "media-kit" doesn't match any registry item -* āœ… `blocks/linear-player/components/pip-control.tsx` — "pip-control" doesn't match any registry item -* āœ… `blocks/linear-player/components/button.tsx` — in `components/` folder, CLI scopes it to the block -* āœ… `blocks/linear-player/lib/test-util.ts` — "test-util" doesn't match any registry item +* āŒ `blocks/video-player/lib/media.ts` — "media" is a limeplay registry item (registryDependency) → import gets hijacked to its target +* āŒ `blocks/video-player/ui/button.tsx` — "button" is a shadcn built-in registry item → import gets hijacked +* āŒ `blocks/video-player/components/picture-in-picture-control.tsx` — same name as limeplay primitive → hijacked +* āœ… `blocks/video-player/components/media-kit.ts` — "media-kit" doesn't match any registry item +* āœ… `blocks/video-player/components/pip-control.tsx` — "pip-control" doesn't match any registry item +* āœ… `blocks/video-player/components/button.tsx` — in `components/` folder, CLI scopes it to the block +* āœ… `blocks/video-player/lib/test-util.ts` — "test-util" doesn't match any registry item #### Folder Scoping in Blocks From 165ca516813e4fd07f8e596535dd846a7a127e9a Mon Sep 17 00:00:00 2001 From: winoffrg Date: Mon, 1 Jun 2026 01:57:16 +0530 Subject: [PATCH 2/5] fix: resolved coderabiit comments --- .../players/audio-player/demo-player.tsx | 11 ++- apps/www/content/docs/blocks/audio-player.mdx | 2 +- apps/www/content/docs/blocks/video-player.mdx | 2 +- apps/www/content/docs/hooks/use-asset.mdx | 27 +++++ .../docs/hooks/use-playback-source.mdx | 58 +++++++++++ .../audio-player/components/audio-source.tsx | 18 ++-- .../blocks/audio-player/components/button.tsx | 5 +- .../audio-player/components/media-player.tsx | 27 ++++- .../components/playback-controls.tsx | 3 +- .../audio-player/components/playlist.tsx | 46 +++------ .../audio-player/components/track-info.tsx | 4 +- .../components/volume-group-control.tsx | 6 +- .../audio-player/hooks/use-playlist-asset.ts | 6 -- .../video-player/components/media-player.tsx | 6 -- .../video-player/components/playlist.tsx | 34 ++++++- apps/www/registry/default/hooks/use-asset.ts | 99 ++++++++++++++++++- .../default/hooks/use-playback-source.ts | 4 + 17 files changed, 281 insertions(+), 77 deletions(-) diff --git a/apps/www/components/players/audio-player/demo-player.tsx b/apps/www/components/players/audio-player/demo-player.tsx index e0f2d66f..5735e53f 100644 --- a/apps/www/components/players/audio-player/demo-player.tsx +++ b/apps/www/components/players/audio-player/demo-player.tsx @@ -23,13 +23,16 @@ export function AudioPlayerDemo({ useEffect(() => { const abortController = new AbortController() - void fetchAudioPlayerDemoPlaylist(playlistId, abortController.signal).then( - (items) => { + void fetchAudioPlayerDemoPlaylist(playlistId, abortController.signal) + .then((items) => { if (!abortController.signal.aborted) { setPlaylist(items) } - } - ) + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return + console.error("Failed to load audio player demo playlist:", error) + }) return () => { abortController.abort() diff --git a/apps/www/content/docs/blocks/audio-player.mdx b/apps/www/content/docs/blocks/audio-player.mdx index 146c2eed..cf266382 100644 --- a/apps/www/content/docs/blocks/audio-player.mdx +++ b/apps/www/content/docs/blocks/audio-player.mdx @@ -19,7 +19,7 @@ npx shadcn add @limeplay/audio-player import { AudioPlayer, type AudioPlayerAsset, -} from "@/components/audio-player/components/media-player" +} from "@/components/limeplay/audio-player/components/media-player" const playlist: AudioPlayerAsset[] = [ { diff --git a/apps/www/content/docs/blocks/video-player.mdx b/apps/www/content/docs/blocks/video-player.mdx index 3f261ecb..70cad183 100644 --- a/apps/www/content/docs/blocks/video-player.mdx +++ b/apps/www/content/docs/blocks/video-player.mdx @@ -19,7 +19,7 @@ npx shadcn add @limeplay/video-player import { VideoPlayer, type VideoPlayerAsset, -} from "@/components/video-player/components/media-player" +} from "@/components/limeplay/video-player/components/media-player" const playlist: VideoPlayerAsset[] = [ { diff --git a/apps/www/content/docs/hooks/use-asset.mdx b/apps/www/content/docs/hooks/use-asset.mdx index 0ab35803..8d4e9f15 100644 --- a/apps/www/content/docs/hooks/use-asset.mdx +++ b/apps/www/content/docs/hooks/use-asset.mdx @@ -109,4 +109,31 @@ useAsset({ name="UseAssetReturn" /> +## Store + +`assetFeature` adds a store slice that tracks active options, load cancellation, retry state, and preload cancellation. + +### State + + + +## Actions + + + +## Events + +`useAsset` listens to player and playlist events instead of exposing a separate event emitter. + + + `useAsset` orchestrates `usePlayer` and `usePlaylist`. It owns load cancellation, playlist-driven loading, preloading, auto-advance on playback end, and error recovery. diff --git a/apps/www/content/docs/hooks/use-playback-source.mdx b/apps/www/content/docs/hooks/use-playback-source.mdx index 10358941..6df75bd6 100644 --- a/apps/www/content/docs/hooks/use-playback-source.mdx +++ b/apps/www/content/docs/hooks/use-playback-source.mdx @@ -48,6 +48,64 @@ export function VideoSource({ playlist }: { playlist: VideoAsset[] }) { } ``` +## 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. + +```tsx title="lib/media.ts" +"use client" + +import { assetFeature } from "@/hooks/limeplay/use-asset" +import { playbackFeature } from "@/hooks/limeplay/use-playback" +import { playerFeature } from "@/hooks/limeplay/use-player" +import { playlistFeature } from "@/hooks/limeplay/use-playlist" +import { createMediaKit } from "@/components/limeplay/media-provider" + +export const media = createMediaKit({ + features: [ + playerFeature(), + playlistFeature(), + playbackFeature(), + assetFeature(), + ] as const, +}) +``` + +```tsx +import type { UsePlaybackSourceOptions } from "@/hooks/limeplay/use-playback-source" +import { PlaybackSourceController } from "@/hooks/limeplay/use-playback-source" + +export function Source( + props: UsePlaybackSourceOptions +) { + return +} +``` + +## Store + +| State | Owner | Description | +| ----- | ----- | ----------- | +| `asset`, `playlist`, `mediaSrc` | `UsePlaybackSourceOptions` | Source inputs 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. | + +## Actions + +| 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`. | + +## 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`. | +| None | `use-playback-source` | The hook itself does not emit custom events. | + ## API Reference Promise } export interface AudioSourceProviderProps { @@ -37,6 +36,7 @@ export interface AudioSourceProviderProps { children?: React.ReactNode getAssetId?: GetAssetId initialIndex?: number + mediaSrc?: string playlist?: AudioPlayerAsset[] resolveSource?: ResolveSource } @@ -62,6 +62,7 @@ export function AudioSourceProvider({ children, getAssetId, initialIndex, + mediaSrc, playlist, resolveSource, }: AudioSourceProviderProps) { @@ -120,6 +121,10 @@ export function AudioSourceProvider({ } const src = await fetchPlaybackUrl(asset.playbackUrls.primary, signal) + if (signal.aborted) { + throw new DOMException("Playback source request aborted", "AbortError") + } + return { config: asset.config, src, @@ -127,16 +132,12 @@ export function AudioSourceProvider({ }, [resolveSource] ) - const refetch = React.useCallback(async () => {}, []) const value = React.useMemo( () => ({ - error: null, - isLoading: false, items, - refetch, }), - [items, refetch] + [items] ) return ( @@ -147,6 +148,7 @@ export function AudioSourceProvider({ autoLoad={autoLoad} getAssetId={resolvedGetAssetId} initialIndex={initialIndex} + mediaSrc={mediaSrc} playlist={playlist} resolveSource={resolvedSource} /> diff --git a/apps/www/registry/default/blocks/audio-player/components/button.tsx b/apps/www/registry/default/blocks/audio-player/components/button.tsx index d12c03bc..e731e484 100644 --- a/apps/www/registry/default/blocks/audio-player/components/button.tsx +++ b/apps/www/registry/default/blocks/audio-player/components/button.tsx @@ -12,6 +12,9 @@ export interface ButtonProps extends React.ButtonHTMLAttributes( ({ asChild = false, className, render, size = "large", ...props }, ref) => { const Comp = render ? Slot : asChild ? Slot : "button" + const buttonProps = + Comp === "button" ? { type: props.type ?? "button", ...props } : props + return ( ( className )} ref={ref} - {...props} + {...buttonProps} > {render ? React.cloneElement(render, undefined, props.children) 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 629f3507..4499328c 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 @@ -1,4 +1,10 @@ -import type { ReactNode } from "react" +"use client" + +import type { + AudioHTMLAttributes, + ComponentPropsWithoutRef, + ReactNode, +} from "react" import type { AudioPlayerAsset, @@ -21,11 +27,19 @@ export interface AudioPlayerProps { asset?: AudioSourceProviderProps["asset"] assetOptions?: AudioSourceProviderProps["assetOptions"] autoLoad?: boolean + autoPlay?: boolean children?: ReactNode className?: string debug?: boolean getAssetId?: AudioSourceProviderProps["getAssetId"] initialIndex?: number + /** + * Props to pass to the underlying audio element. + */ + mediaProps?: Omit< + AudioHTMLAttributes, + "as" | "autoPlay" | "className" + > playlist?: AudioPlayerAsset[] resolveSource?: AudioSourceProviderProps["resolveSource"] } @@ -34,14 +48,18 @@ export function AudioPlayer({ asset, assetOptions, autoLoad, + autoPlay = false, children, className, debug, getAssetId, initialIndex, + mediaProps, playlist, resolveSource, }: AudioPlayerProps = {}) { + const { src: mediaSrc, ...safeMediaProps } = mediaProps ?? {} + return ( @@ -60,7 +79,11 @@ export function AudioPlayer({ className )} > - + )} + as="audio" + autoPlay={autoPlay} + />
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 f466af8e..fa27d261 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 @@ -120,7 +120,8 @@ function useStablePlaybackState( timeoutRef.current = null } } - }, [isBuffering, delayMs]) + // DEV: The spinner from lastIntentRef.current, but this effect never reruns when status changes while isBuffering stays true. A pause during buffering can still show the delayed loading spinner because the old timeout is left in place. + }, [isBuffering, delayMs, status]) // During transient buffering, hold the last intent as the visual state const stableIsPlaying = isBuffering 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 fbac35a9..c7b8d89f 100644 --- a/apps/www/registry/default/blocks/audio-player/components/playlist.tsx +++ b/apps/www/registry/default/blocks/audio-player/components/playlist.tsx @@ -1,7 +1,7 @@ "use client" import { CardsThreeIcon, PlayIcon } from "@phosphor-icons/react" -import { Loader2, Volume2Icon } from "lucide-react" +import { Volume2Icon } from "lucide-react" import { useCallback, useMemo, useRef } from "react" import type { AudioPlayerAsset } from "@/registry/default/blocks/audio-player/components/audio-source" @@ -23,7 +23,7 @@ export function Playlist() { const { currentItem, isPreloaded, orderedItems, skipToId } = useAsset() - const { error, isLoading, items, refetch } = useAudioSource() + const { items } = useAudioSource() const displayAssets = useMemo(() => { if (orderedItems.length > 0) @@ -97,39 +97,13 @@ export function Playlist() { ref={scrollRef} style={{ scrollbarWidth: "none" }} > - {isLoading && ( -
- -
- )} - - {error && ( -
-

- Couldn't load queue -

- -
- )} - - {!isLoading && !error && displayAssets.length === 0 && ( + {displayAssets.length === 0 && (
Queue is empty
)} - {!isLoading && - !error && - displayAssets.map(({ asset, id }, index) => ( + {displayAssets.map(({ asset, id }, index) => (
- + {asset.poster && ( + + )}
@@ -44,7 +45,8 @@ export function TrackInfo() {
{genre && (
- {genre} • 2026 + {genre} + {releaseYear ? ` • ${releaseYear}` : ""}
)}
diff --git a/apps/www/registry/default/blocks/audio-player/components/volume-group-control.tsx b/apps/www/registry/default/blocks/audio-player/components/volume-group-control.tsx index c1124036..83cf49b9 100644 --- a/apps/www/registry/default/blocks/audio-player/components/volume-group-control.tsx +++ b/apps/www/registry/default/blocks/audio-player/components/volume-group-control.tsx @@ -58,7 +58,11 @@ export function VolumeControl() { key={isMuted ? "muted" : "unmuted"} transition={{ bounce: 0, duration: 0.3, type: "spring" }} > - {isMuted ? : } + {isMuted ? ( +
diff --git a/apps/www/registry/default/blocks/video-player/components/playlist.tsx b/apps/www/registry/default/blocks/video-player/components/playlist.tsx index ba893372..d15b9c0c 100644 --- a/apps/www/registry/default/blocks/video-player/components/playlist.tsx +++ b/apps/www/registry/default/blocks/video-player/components/playlist.tsx @@ -21,7 +21,8 @@ import { usePlaylistStore } from "@/registry/default/hooks/use-playlist" export function Playlist() { const currentItem = usePlaylistStore( - (state) => state.currentItem as null | { id: string; properties: VideoPlayerAsset } + (state) => + state.currentItem as null | { id: string; properties: VideoPlayerAsset } ) const preloadAsset = useAssetStore((state) => state.preloadAsset) as ( asset: VideoPlayerAsset diff --git a/apps/www/registry/default/hooks/use-asset.ts b/apps/www/registry/default/hooks/use-asset.ts index aa6afcff..aba4ad9c 100644 --- a/apps/www/registry/default/hooks/use-asset.ts +++ b/apps/www/registry/default/hooks/use-asset.ts @@ -251,7 +251,8 @@ export function assetFeature(): MediaFeature< if (asset.optionsOwnerId !== ownerId) return const nextOwnerId = - asset.optionsOwnerOrder[asset.optionsOwnerOrder.length - 1] ?? null + asset.optionsOwnerOrder[asset.optionsOwnerOrder.length - 1] ?? + null asset.optionsOwnerId = nextOwnerId asset.installedOptions = nextOwnerId ? asset.optionsByOwner[nextOwnerId] @@ -499,8 +500,8 @@ export function assetFeature(): MediaFeature< const isCurrentPreload = () => { return ( - get().asset.preloadAbortControllers[assetId] === abortController && - !abortController.signal.aborted + get().asset.preloadAbortControllers[assetId] === + abortController && !abortController.signal.aborted ) } @@ -663,8 +664,8 @@ export function useAsset( const cancelPreload = useCallback( (assetId: string) => { - const preloadAbortController = api.getState().asset - .preloadAbortControllers[assetId] + const preloadAbortController = + api.getState().asset.preloadAbortControllers[assetId] preloadAbortController?.abort() const preloadManagers = (api.getState() as unknown as PlayerStore).player From 260babe62214d56eae9742c48ed103b1e59b91f1 Mon Sep 17 00:00:00 2001 From: winoffrg Date: Mon, 1 Jun 2026 22:16:10 +0530 Subject: [PATCH 4/5] chore: ai comments resolve --- .../audio-player/components/playlist.tsx | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) 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 6cbe479e..d639fc66 100644 --- a/apps/www/registry/default/blocks/audio-player/components/playlist.tsx +++ b/apps/www/registry/default/blocks/audio-player/components/playlist.tsx @@ -14,16 +14,32 @@ import { import { cn } from "@/lib/utils" import { useAudioSource } from "@/registry/default/blocks/audio-player/components/audio-source" import { Button } from "@/registry/default/blocks/audio-player/components/button" -import { useAsset } from "@/registry/default/hooks/use-asset" +import { usePlayerStore } from "@/registry/default/hooks/use-player" import { usePlaylistStore } from "@/registry/default/hooks/use-playlist" export function Playlist() { + const currentItem = usePlaylistStore( + (state) => state.currentItem as null | { id: string; properties: AudioPlayerAsset } + ) + const preloadManagers = usePlayerStore((state) => state.preloadManagers) + const queue = usePlaylistStore( + (state) => state.queue as { id: string; properties: AudioPlayerAsset }[] + ) const shuffle = usePlaylistStore((state) => state.shuffle) + const shuffleOrder = usePlaylistStore((state) => state.shuffleOrder) + const skipToId = usePlaylistStore((state) => state.skipToId) const scrollRef = useRef(null) - const { currentItem, isPreloaded, orderedItems, skipToId } = - useAsset() const { items } = useAudioSource() + const orderedItems = useMemo(() => { + if (!shuffle || shuffleOrder.length === 0) return queue + + return shuffleOrder + .map((index) => queue[index]) + .filter((item): item is { id: string; properties: AudioPlayerAsset } => + Boolean(item) + ) + }, [queue, shuffle, shuffleOrder]) const displayAssets = useMemo(() => { if (orderedItems.length > 0) @@ -110,7 +126,8 @@ export function Playlist() { isActive={currentItem?.id === id} key={id} onSelect={() => handleAssetSelect(id)} - preloaded={isPreloaded(id)} + preloaded={preloadManagers.has(id)} + setSize={displayAssets.length} /> ))}
@@ -130,17 +147,23 @@ function formatDuration(ms?: number) { function TrackRow({ asset, + index, isActive, onSelect, + preloaded, + setSize, }: { asset: AudioPlayerAsset index: number isActive: boolean onSelect: () => void preloaded: boolean + setSize: number }) { return (