
+
+
+ )
+}
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 (
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/audio-player/hooks/use-playlist-asset.ts b/apps/www/registry/default/blocks/audio-player/hooks/use-playlist-asset.ts
index f35a9cf2..1cfa4f53 100644
--- 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
@@ -14,10 +14,7 @@ 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 {
@@ -26,9 +23,6 @@ export function usePlaylistAsset(): UsePlaylistAssetReturn {
return {
...asset,
- error: source.error,
- isLoading: source.isLoading,
items: source.items,
- refetch: source.refetch,
}
}
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
index f484c5e2..300e7105 100644
--- a/apps/www/registry/default/blocks/video-player/components/media-player.tsx
+++ b/apps/www/registry/default/blocks/video-player/components/media-player.tsx
@@ -42,10 +42,6 @@ export interface VideoPlayerProps {
React.VideoHTMLAttributes,
"as" | "className"
>
- /**
- * Ref to the underlying video element.
- */
- mediaRef?: React.Ref
playlist?: VideoPlayerAsset[]
resolveSource?: ResolveSource
}
@@ -61,7 +57,6 @@ export const VideoPlayer = React.forwardRef(
getAssetId,
initialIndex,
mediaProps,
- mediaRef,
playlist,
resolveSource,
},
@@ -91,7 +86,6 @@ export const VideoPlayer = React.forwardRef(
>)}
as="video"
className="size-full object-cover"
- ref={mediaRef as React.Ref}
/>
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 704d4edb..ba893372 100644
--- a/apps/www/registry/default/blocks/video-player/components/playlist.tsx
+++ b/apps/www/registry/default/blocks/video-player/components/playlist.tsx
@@ -1,6 +1,7 @@
"use client"
import { CardsThreeIcon, PlayIcon } from "@phosphor-icons/react"
+import { useMemo } from "react"
import type { VideoPlayerAsset } from "@/registry/default/blocks/video-player/components/media-player"
@@ -14,18 +15,41 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/registry/default/blocks/video-player/components/button"
-import { useAsset } from "@/registry/default/hooks/use-asset"
+import { useAssetStore } 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, isPreloaded, orderedItems, preloadAsset, skipToId } =
- useAsset()
+ const currentItem = usePlaylistStore(
+ (state) => state.currentItem as null | { id: string; properties: VideoPlayerAsset }
+ )
+ const preloadAsset = useAssetStore((state) => state.preloadAsset) as (
+ asset: VideoPlayerAsset
+ ) => Promise
+ const preloadManagers = usePlayerStore((state) => state.preloadManagers)
+ const queue = usePlaylistStore(
+ (state) => state.queue as { id: string; properties: VideoPlayerAsset }[]
+ )
+ const shuffle = usePlaylistStore((state) => state.shuffle)
+ const shuffleOrder = usePlaylistStore((state) => state.shuffleOrder)
+ const skipToId = usePlaylistStore((state) => state.skipToId)
+
+ const orderedItems = useMemo(() => {
+ if (!shuffle || shuffleOrder.length === 0) return queue
+
+ return shuffleOrder
+ .map((index) => queue[index])
+ .filter((item): item is { id: string; properties: VideoPlayerAsset } =>
+ Boolean(item)
+ )
+ }, [queue, shuffle, shuffleOrder])
const handleAssetSelect = async (assetId: string) => {
await skipToId(assetId)
}
const handleAssetHover = async (assetId: string, asset: VideoPlayerAsset) => {
- if (!isPreloaded(assetId) && currentItem?.id !== assetId) {
+ if (!preloadManagers.has(assetId) && currentItem?.id !== assetId) {
await preloadAsset(asset)
}
}
@@ -51,7 +75,7 @@ export function Playlist() {
{orderedItems.map((item) => {
const asset = item.properties
const isCurrentAsset = currentItem?.id === item.id
- const isAssetPreloaded = isPreloaded(item.id)
+ const isAssetPreloaded = preloadManagers.has(item.id)
return (
{
startTime?: number
}
+export interface UseAssetActions {
+ clearOptions: (ownerId: string) => void
+ loadAsset: (asset: Asset, startTime?: number) => Promise
+ loadPlaylist: (assets: Asset[], startIndex?: number) => void
+ preloadAsset: (asset: Asset) => Promise
+ preloadNext: () => Promise
+ setOptions: (options: UseAssetOptions, ownerId: string) => void
+}
+
+export interface UseAssetEvents {
+ ended: "advance to the next playlist item when available"
+ playbackerror: "delegate reload, skip, or stop decisions to onPlaybackError"
+ playlistchange: "load the newly active playlist item"
+}
+
export type UseAssetLoadContext = AssetLoadContext
export interface UseAssetLoader {
@@ -175,6 +190,16 @@ export interface UseAssetReturn {
toggleShuffle: () => void
}
+export interface UseAssetState {
+ installedOptions?: UseAssetOptions
+ isFirstLoad: boolean
+ loadAbortController: AbortController | null
+ loadGeneration: number
+ optionsOwnerId: null | string
+ previousError: unknown
+ retryCount: number
+}
+
export const ASSET_FEATURE_KEY = "asset"
export interface AssetStore {
@@ -186,7 +211,10 @@ export interface AssetStore {
loadAsset: (asset: Asset, startTime?: number) => Promise
loadGeneration: number
loadPlaylist: (assets: Asset[], startIndex?: number) => void
+ optionsByOwner: Record>
optionsOwnerId: null | string
+ optionsOwnerOrder: string[]
+ preloadAbortControllers: Record
preloadAsset: (asset: Asset) => Promise
preloadNext: () => Promise
previousError: unknown
@@ -212,11 +240,22 @@ export function assetFeature(): MediaFeature<
createSlice: (set, get) => ({
[ASSET_FEATURE_KEY]: {
clearOptions: (ownerId) => {
- if (get().asset.optionsOwnerId !== ownerId) return
+ if (!get().asset.optionsByOwner[ownerId]) return
set(({ asset }) => {
- asset.installedOptions = undefined
- asset.optionsOwnerId = null
+ delete asset.optionsByOwner[ownerId]
+ asset.optionsOwnerOrder = asset.optionsOwnerOrder.filter(
+ (id) => id !== ownerId
+ )
+
+ if (asset.optionsOwnerId !== ownerId) return
+
+ const nextOwnerId =
+ asset.optionsOwnerOrder[asset.optionsOwnerOrder.length - 1] ?? null
+ asset.optionsOwnerId = nextOwnerId
+ asset.installedOptions = nextOwnerId
+ ? asset.optionsByOwner[nextOwnerId]
+ : undefined
})
},
installedOptions: undefined,
@@ -437,7 +476,10 @@ export function assetFeature(): MediaFeature<
get().playlist.load(normalizedAssets, startIndex)
},
+ optionsByOwner: {},
optionsOwnerId: null,
+ optionsOwnerOrder: [],
+ preloadAbortControllers: {},
preloadAsset: async (asset) => {
const player = get().player.instance as null | shaka.Player
const options = get().asset.installedOptions as
@@ -448,15 +490,29 @@ export function assetFeature(): MediaFeature<
const assetId = getNormalizedAssetId(asset, options, {
origin: "asset",
})
+ get().asset.preloadAbortControllers[assetId]?.abort()
+ const abortController = new AbortController()
+
+ set(({ asset }) => {
+ asset.preloadAbortControllers[assetId] = abortController
+ })
+
+ const isCurrentPreload = () => {
+ return (
+ get().asset.preloadAbortControllers[assetId] === abortController &&
+ !abortController.signal.aborted
+ )
+ }
try {
const retryCount = get().asset.retryCount
const previousError = get().asset.previousError ?? undefined
- const abortController = new AbortController()
const preloadDefault = async (
source?: PlaybackSource | string
): Promise => {
+ if (!isCurrentPreload()) return null
+
const normalizedSource = normalizePlaybackSource(source)
const resolvedSource = normalizedSource.source ?? asset.src
@@ -466,12 +522,18 @@ export function assetFeature(): MediaFeature<
)
}
- return player.preload(
+ const manager = await player.preload(
resolvedSource,
undefined,
undefined,
mergePlayerConfiguration(asset.config, normalizedSource.config)
)
+ if (!isCurrentPreload()) {
+ void manager?.destroy()
+ return null
+ }
+
+ return manager
}
let manager: null | shaka.media.PreloadManager = null
@@ -494,9 +556,16 @@ export function assetFeature(): MediaFeature<
signal: abortController.signal,
})
+ if (!isCurrentPreload()) return
+
manager = await preloadDefault(resolved ?? undefined)
}
+ if (!isCurrentPreload()) {
+ void manager?.destroy()
+ return
+ }
+
if (manager) {
const existing = get().player.preloadManagers as Map<
string,
@@ -515,6 +584,13 @@ export function assetFeature(): MediaFeature<
})
}
} catch (error) {
+ if (
+ abortController.signal.aborted ||
+ (error instanceof DOMException && error.name === "AbortError")
+ ) {
+ return
+ }
+
const err = toError(error)
options?.onError?.(err, asset)
console.error("[useAsset] Preload error:", error)
@@ -536,6 +612,11 @@ export function assetFeature(): MediaFeature<
retryCount: 0,
setOptions: (options, ownerId) => {
set(({ asset }) => {
+ asset.optionsByOwner[ownerId] = options
+ asset.optionsOwnerOrder = [
+ ...asset.optionsOwnerOrder.filter((id) => id !== ownerId),
+ ownerId,
+ ]
asset.installedOptions = options
asset.optionsOwnerId = ownerId
})
@@ -582,6 +663,10 @@ export function useAsset(
const cancelPreload = useCallback(
(assetId: string) => {
+ const preloadAbortController = api.getState().asset
+ .preloadAbortControllers[assetId]
+ preloadAbortController?.abort()
+
const preloadManagers = (api.getState() as unknown as PlayerStore).player
.preloadManagers as Map
const manager = preloadManagers.get(assetId)
@@ -595,6 +680,10 @@ export function useAsset(
}
)
}
+
+ ;(api as unknown as ImmerStoreApi).setState(({ asset }) => {
+ delete asset.preloadAbortControllers[assetId]
+ })
},
[api]
)
diff --git a/apps/www/registry/default/hooks/use-playback-source.ts b/apps/www/registry/default/hooks/use-playback-source.ts
index 69f631e8..5eaacfbd 100644
--- a/apps/www/registry/default/hooks/use-playback-source.ts
+++ b/apps/www/registry/default/hooks/use-playback-source.ts
@@ -59,6 +59,10 @@ export function usePlaybackSource(
const { loadPlaylist } = useAsset(installedOptions)
const loadedSourceKeyRef = useRef(null)
+ useEffect(() => {
+ loadedSourceKeyRef.current = null
+ }, [player])
+
const assets = useMemo(() => {
if (playlist) return playlist
if (asset) return [asset]
From 5436ea9e9041c6df33d2f4b83abb807090328604 Mon Sep 17 00:00:00 2001
From: winoffrg
Date: Mon, 1 Jun 2026 01:57:28 +0530
Subject: [PATCH 3/5] fix: resolved coderabiit comments
---
.../audio-player/components/playlist.tsx | 18 +++++++++---------
.../video-player/components/playlist.tsx | 3 ++-
apps/www/registry/default/hooks/use-asset.ts | 11 ++++++-----
3 files changed, 17 insertions(+), 15 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 c7b8d89f..6cbe479e 100644
--- a/apps/www/registry/default/blocks/audio-player/components/playlist.tsx
+++ b/apps/www/registry/default/blocks/audio-player/components/playlist.tsx
@@ -104,15 +104,15 @@ export function Playlist() {
)}
{displayAssets.map(({ asset, id }, index) => (
- handleAssetSelect(id)}
- preloaded={isPreloaded(id)}
- />
- ))}
+ handleAssetSelect(id)}
+ preloaded={isPreloaded(id)}
+ />
+ ))}
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