
+
+
+ )
+}
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..aba4ad9c 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,103 @@ 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 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 {
- 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 +145,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
@@ -131,25 +190,48 @@ 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 {
[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
+ optionsByOwner: Record>
+ optionsOwnerId: null | string
+ optionsOwnerOrder: string[]
+ preloadAbortControllers: Record
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 +239,26 @@ export function assetFeature(): MediaFeature<
return {
createSlice: (set, get) => ({
[ASSET_FEATURE_KEY]: {
+ clearOptions: (ownerId) => {
+ if (!get().asset.optionsByOwner[ownerId]) return
+
+ set(({ asset }) => {
+ 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,
isFirstLoad: true,
loadAbortController: null,
@@ -171,6 +273,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 +289,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 +393,7 @@ export function assetFeature(): MediaFeature<
set(({ asset }) => {
asset.isFirstLoad = false
+ asset.previousError = null
asset.retryCount = 0
})
@@ -272,17 +408,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 +438,7 @@ export function assetFeature(): MediaFeature<
hasNext
) {
set(({ asset }) => {
+ asset.previousError = null
asset.retryCount = 0
})
playlist.next()
@@ -309,6 +446,7 @@ export function assetFeature(): MediaFeature<
}
set(({ asset }) => {
+ asset.previousError = null
asset.retryCount = 0
})
return false
@@ -316,6 +454,7 @@ export function assetFeature(): MediaFeature<
if (hasNext) {
set(({ asset }) => {
+ asset.previousError = null
asset.retryCount = 0
})
playlist.next()
@@ -325,18 +464,23 @@ 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)
},
+ optionsByOwner: {},
+ optionsOwnerId: null,
+ optionsOwnerOrder: [],
+ preloadAbortControllers: {},
preloadAsset: async (asset) => {
const player = get().player.instance as null | shaka.Player
const options = get().asset.installedOptions as
@@ -344,20 +488,53 @@ export function assetFeature(): MediaFeature<
| UseAssetOptions
if (!player) return
- const { onError: onPlayerError, onPreload } =
- options?.playerOptions ?? {}
+ 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 defaultPreload = async (
- source?: string
+ const retryCount = get().asset.retryCount
+ const previousError = get().asset.previousError ?? undefined
+
+ const preloadDefault = async (
+ source?: PlaybackSource | string
): Promise => {
- const resolvedSource = source ?? asset.src
- return player.preload(
+ if (!isCurrentPreload()) return null
+
+ 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.`
+ )
+ }
+
+ const manager = await player.preload(
resolvedSource,
undefined,
undefined,
- asset.config
+ mergePlayerConfiguration(asset.config, normalizedSource.config)
)
+ if (!isCurrentPreload()) {
+ void manager?.destroy()
+ return null
+ }
+
+ return manager
}
let manager: null | shaka.media.PreloadManager = null
@@ -365,13 +542,29 @@ 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,
+ })
+
+ if (!isCurrentPreload()) return
+
+ manager = await preloadDefault(resolved ?? undefined)
+ }
+
+ if (!isCurrentPreload()) {
+ void manager?.destroy()
+ return
}
if (manager) {
@@ -379,25 +572,28 @@ 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)
+ 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)
}
},
@@ -413,10 +609,17 @@ export function assetFeature(): MediaFeature<
)
}
},
+ previousError: null,
retryCount: 0,
- setOptions: (options) => {
+ setOptions: (options, ownerId) => {
set(({ asset }) => {
+ asset.optionsByOwner[ownerId] = options
+ asset.optionsOwnerOrder = [
+ ...asset.optionsOwnerOrder.filter((id) => id !== ownerId),
+ ownerId,
+ ]
asset.installedOptions = options
+ asset.optionsOwnerId = ownerId
})
},
},
@@ -425,11 +628,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,13 +650,24 @@ 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) => {
+ 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)
@@ -467,6 +681,10 @@ export function useAsset(
}
)
}
+
+ ;(api as unknown as ImmerStoreApi).setState(({ asset }) => {
+ delete asset.preloadAbortControllers[assetId]
+ })
},
[api]
)
@@ -547,6 +765,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 +796,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..5eaacfbd
--- /dev/null
+++ b/apps/www/registry/default/hooks/use-playback-source.ts
@@ -0,0 +1,100 @@
+"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)
+
+ useEffect(() => {
+ loadedSourceKeyRef.current = null
+ }, [player])
+
+ 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 (