Dice UI
Components

Media Player

A fully featured media player component supporting video and audio playback with custom controls.

Media playerAudio player with custom controls for playback, volume, seeking, and more. Use space bar to play/pause, Shift + arrow keys (←/→) to seek, and arrow keys (↑/↓) to adjust volume.
0:000:00
import {
  MediaPlayer,
  MediaPlayerControls,
  MediaPlayerControlsOverlay,
  MediaPlayerFullscreen,
  MediaPlayerPiP,
  MediaPlayerPlay,
  MediaPlayerPlaybackSpeed,
  MediaPlayerSeek,
  MediaPlayerSeekBackward,
  MediaPlayerSeekForward,
  MediaPlayerTime,
  MediaPlayerVideo,
  MediaPlayerVolume,
} from "@/components/ui/media-player";
 
export function MediaPlayerDemo() {
  return (
    <MediaPlayer>
      <MediaPlayerVideo>
        <source src="/assets/cloud.mp4" type="video/mp4" />
      </MediaPlayerVideo>
      <MediaPlayerControls className="flex-col items-start gap-2.5">
        <MediaPlayerControlsOverlay />
        <MediaPlayerSeek />
        <div className="flex w-full items-center gap-2">
          <div className="flex flex-1 items-center gap-2">
            <MediaPlayerPlay />
            <MediaPlayerSeekBackward />
            <MediaPlayerSeekForward />
            <MediaPlayerVolume expandable />
            <MediaPlayerTime />
          </div>
          <div className="flex items-center gap-2">
            <MediaPlayerPlaybackSpeed />
            <MediaPlayerPiP />
            <MediaPlayerFullscreen />
          </div>
        </div>
      </MediaPlayerControls>
    </MediaPlayer>
  );
}

Installation

Prerequisites: Before installing the media player component, you'll need to update your existing shadcn/ui components to support portal containers.

DropdownMenu

function DropdownMenuContent({
  className,
+ container,
  sideOffset = 4,
  ...props
-}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Content> & {
+  container?: DropdownMenuPrimitive.DropdownMenuPortalProps["container"];
+}) {
  return (
-    <DropdownMenuPrimitive.Portal>
+    <DropdownMenuPrimitive.Portal container={container}>

Tooltip

function TooltipContent({
  className,
  sideOffset = 0,
+ container,
  children,
  ...props
-}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
+}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
+  container?: TooltipPrimitive.TooltipPortalProps["container"];
+}) {
  return (
-    <TooltipPrimitive.Portal>
+    <TooltipPrimitive.Portal container={container}>

CLI

After completing the prerequisite component updates, install the media player:

npx shadcn@latest add "https://diceui.com/r/media-player"
pnpm dlx shadcn@latest add "https://diceui.com/r/media-player"
yarn dlx shadcn@latest add "https://diceui.com/r/media-player"
bun x shadcn@latest add "https://diceui.com/r/media-player"

Manual

After completing the prerequisite component updates, install the required dependencies:

npm install @radix-ui/react-slot @radix-ui/react-slider @radix-ui/react-tooltip lucide-react
pnpm add @radix-ui/react-slot @radix-ui/react-slider @radix-ui/react-tooltip lucide-react
yarn add @radix-ui/react-slot @radix-ui/react-slider @radix-ui/react-tooltip lucide-react
bun add @radix-ui/react-slot @radix-ui/react-slider @radix-ui/react-tooltip lucide-react

Copy the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // eslint-disable-next-line react-hooks/exhaustive-deps
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy and paste the following code into your project.

"use client";
 
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { Slot } from "@radix-ui/react-slot";
import {
  AlertTriangleIcon,
  CaptionsOffIcon,
  CheckIcon,
  DownloadIcon,
  FastForwardIcon,
  Loader2Icon,
  Maximize2Icon,
  Minimize2Icon,
  PauseIcon,
  PictureInPicture2Icon,
  PictureInPictureIcon,
  PlayIcon,
  RefreshCcwIcon,
  RepeatIcon,
  RewindIcon,
  RotateCcwIcon,
  SettingsIcon,
  SubtitlesIcon,
  Volume1Icon,
  Volume2Icon,
  VolumeXIcon,
} from "lucide-react";
import {
  MediaActionTypes,
  MediaProvider,
  timeUtils,
  useMediaDispatch,
  useMediaFullscreenRef,
  useMediaRef,
  useMediaSelector,
} from "media-chrome/react/media-store";
import * as React from "react";
import * as ReactDOM from "react-dom";
 
const ROOT_NAME = "MediaPlayer";
const SEEK_NAME = "MediaPlayerSeek";
const SETTINGS_NAME = "MediaPlayerSettings";
const VOLUME_NAME = "MediaPlayerVolume";
const PLAYBACK_SPEED_NAME = "MediaPlayerPlaybackSpeed";
 
const LOADING_DELAY_MS = 500;
const FLOATING_MENU_SIDE_OFFSET = 10;
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
 
const SEEK_AMOUNT_SHORT = 5;
const SEEK_AMOUNT_LONG = 10;
const SEEK_COLLISION_PADDING = 10;
const SEEK_TOOLTIP_WIDTH_FALLBACK = 240;
 
const SEEK_HOVER_PERCENT = "--seek-hover-percent";
const SEEK_TOOLTIP_X = "--seek-tooltip-x";
const SEEK_TOOLTIP_Y = "--seek-tooltip-y";
 
const SPRITE_CONTAINER_WIDTH = 224;
const SPRITE_CONTAINER_HEIGHT = 128;
 
type Direction = "ltr" | "rtl";
 
const DirectionContext = React.createContext<Direction | undefined>(undefined);
 
function useDirection(dirProp?: Direction): Direction {
  const contextDir = React.useContext(DirectionContext);
  return dirProp ?? contextDir ?? "ltr";
}
 
function useLazyRef<T>(fn: () => T) {
  const ref = React.useRef<T | null>(null);
 
  if (ref.current === null) {
    ref.current = fn();
  }
 
  return ref as React.RefObject<T>;
}
 
interface StoreState {
  controlsVisible: boolean;
  menuOpen: boolean;
  dragging: boolean;
  volumeIndicatorVisible: boolean;
}
 
interface Store {
  subscribe: (cb: () => void) => () => void;
  getState: () => StoreState;
  setState: (
    key: keyof StoreState,
    value: StoreState[keyof StoreState],
  ) => void;
  notify: () => void;
}
 
function createStore(
  listenersRef: React.RefObject<Set<() => void>>,
  stateRef: React.RefObject<StoreState>,
  onValueChange?: Partial<{
    [K in keyof StoreState]: (value: StoreState[K], store: Store) => void;
  }>,
): Store {
  const store: Store = {
    subscribe: (cb) => {
      listenersRef.current.add(cb);
      return () => listenersRef.current.delete(cb);
    },
    getState: () => stateRef.current,
    setState: (key, value) => {
      if (Object.is(stateRef.current[key], value)) return;
      stateRef.current[key] = value;
      onValueChange?.[key]?.(value, store);
      store.notify();
    },
    notify: () => {
      for (const cb of listenersRef.current) {
        cb();
      }
    },
  };
 
  return store;
}
 
const StoreContext = React.createContext<Store | null>(null);
 
function useStoreContext(consumerName: string) {
  const context = React.useContext(StoreContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
function useStoreSelector<U>(selector: (state: StoreState) => U): U {
  const storeContext = useStoreContext("useStoreSelector");
 
  const getSnapshot = React.useCallback(
    () => selector(storeContext.getState()),
    [storeContext, selector],
  );
 
  return React.useSyncExternalStore(
    storeContext.subscribe,
    getSnapshot,
    getSnapshot,
  );
}
 
interface MediaPlayerContextValue {
  mediaId: string;
  labelId: string;
  descriptionId: string;
  dir: Direction;
  rootRef: React.RefObject<HTMLDivElement | null>;
  mediaRef: React.RefObject<HTMLVideoElement | HTMLAudioElement | null>;
  portalContainer: Element | DocumentFragment | null;
  tooltipDelayDuration: number;
  tooltipSideOffset: number;
  disabled: boolean;
  isVideo: boolean;
  withoutTooltip: boolean;
}
 
const MediaPlayerContext = React.createContext<MediaPlayerContextValue | null>(
  null,
);
 
function useMediaPlayerContext(consumerName: string) {
  const context = React.useContext(MediaPlayerContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
interface MediaPlayerRootProps
  extends Omit<React.ComponentProps<"div">, "onTimeUpdate" | "onVolumeChange"> {
  onPlay?: () => void;
  onPause?: () => void;
  onEnded?: () => void;
  onTimeUpdate?: (time: number) => void;
  onVolumeChange?: (volume: number) => void;
  onMuted?: (muted: boolean) => void;
  onMediaError?: (error: MediaError | null) => void;
  onPipError?: (error: unknown, state: "enter" | "exit") => void;
  onFullscreenChange?: (fullscreen: boolean) => void;
  dir?: Direction;
  label?: string;
  tooltipDelayDuration?: number;
  tooltipSideOffset?: number;
  asChild?: boolean;
  autoHide?: boolean;
  disabled?: boolean;
  withoutTooltip?: boolean;
}
 
function MediaPlayerRoot(props: MediaPlayerRootProps) {
  const listenersRef = useLazyRef(() => new Set<() => void>());
  const stateRef = useLazyRef<StoreState>(() => ({
    controlsVisible: true,
    menuOpen: false,
    dragging: false,
    volumeIndicatorVisible: false,
  }));
 
  const store = React.useMemo(
    () => createStore(listenersRef, stateRef),
    [listenersRef, stateRef],
  );
 
  return (
    <MediaProvider>
      <StoreContext.Provider value={store}>
        <MediaPlayerRootImpl {...props} />
      </StoreContext.Provider>
    </MediaProvider>
  );
}
 
function MediaPlayerRootImpl(props: MediaPlayerRootProps) {
  const {
    onPlay,
    onPause,
    onEnded,
    onTimeUpdate,
    onFullscreenChange,
    onVolumeChange,
    onMuted,
    onMediaError,
    onPipError,
    dir: dirProp,
    label,
    tooltipDelayDuration = 600,
    tooltipSideOffset = FLOATING_MENU_SIDE_OFFSET,
    asChild,
    autoHide = false,
    disabled = false,
    withoutTooltip = false,
    children,
    className,
    ref,
    ...rootImplProps
  } = props;
 
  const mediaId = React.useId();
  const labelId = React.useId();
  const descriptionId = React.useId();
 
  const rootRef = React.useRef<HTMLDivElement | null>(null);
  const fullscreenRef = useMediaFullscreenRef();
  const composedRef = useComposedRefs(ref, rootRef, fullscreenRef);
 
  const dir = useDirection(dirProp);
  const dispatch = useMediaDispatch();
  const mediaRef = React.useRef<HTMLVideoElement | HTMLAudioElement | null>(
    null,
  );
 
  const store = useStoreContext(ROOT_NAME);
 
  const controlsVisible = useStoreSelector((state) => state.controlsVisible);
  const dragging = useStoreSelector((state) => state.dragging);
  const menuOpen = useStoreSelector((state) => state.menuOpen);
 
  const hideControlsTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
  const lastMouseMoveRef = React.useRef<number>(Date.now());
  const volumeIndicatorTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
 
  const mediaPaused = useMediaSelector((state) => state.mediaPaused ?? true);
  const isFullscreen = useMediaSelector(
    (state) => state.mediaIsFullscreen ?? false,
  );
 
  const [mounted, setMounted] = React.useState(false);
  React.useLayoutEffect(() => setMounted(true), []);
 
  const portalContainer = mounted
    ? isFullscreen
      ? rootRef.current
      : globalThis.document.body
    : null;
 
  const isVideo =
    (typeof HTMLVideoElement !== "undefined" &&
      mediaRef.current instanceof HTMLVideoElement) ||
    mediaRef.current?.tagName?.toLowerCase() === "mux-player";
 
  const onControlsShow = React.useCallback(() => {
    store.setState("controlsVisible", true);
    lastMouseMoveRef.current = Date.now();
 
    if (hideControlsTimeoutRef.current) {
      clearTimeout(hideControlsTimeoutRef.current);
    }
 
    if (autoHide && !mediaPaused && !menuOpen && !dragging) {
      hideControlsTimeoutRef.current = setTimeout(() => {
        store.setState("controlsVisible", false);
      }, 3000);
    }
  }, [store.setState, autoHide, mediaPaused, menuOpen, dragging]);
 
  const onVolumeIndicatorTrigger = React.useCallback(() => {
    if (menuOpen) return;
 
    store.setState("volumeIndicatorVisible", true);
 
    if (volumeIndicatorTimeoutRef.current) {
      clearTimeout(volumeIndicatorTimeoutRef.current);
    }
 
    volumeIndicatorTimeoutRef.current = setTimeout(() => {
      store.setState("volumeIndicatorVisible", false);
    }, 2000);
 
    if (autoHide) {
      onControlsShow();
    }
  }, [store.setState, menuOpen, autoHide, onControlsShow]);
 
  const onMouseLeave = React.useCallback(
    (event: React.MouseEvent<HTMLDivElement>) => {
      rootImplProps.onMouseLeave?.(event);
 
      if (event.defaultPrevented) return;
 
      if (autoHide && !mediaPaused && !menuOpen && !dragging) {
        store.setState("controlsVisible", false);
      }
    },
    [
      store.setState,
      rootImplProps.onMouseLeave,
      autoHide,
      mediaPaused,
      menuOpen,
      dragging,
    ],
  );
 
  const onMouseMove = React.useCallback(
    (event: React.MouseEvent<HTMLDivElement>) => {
      rootImplProps.onMouseMove?.(event);
 
      if (event.defaultPrevented) return;
 
      if (autoHide) {
        onControlsShow();
      }
    },
    [autoHide, rootImplProps.onMouseMove, onControlsShow],
  );
 
  React.useEffect(() => {
    if (mediaPaused || menuOpen || dragging) {
      store.setState("controlsVisible", true);
      if (hideControlsTimeoutRef.current) {
        clearTimeout(hideControlsTimeoutRef.current);
      }
      return;
    }
 
    if (autoHide) {
      onControlsShow();
    }
  }, [
    store.setState,
    onControlsShow,
    autoHide,
    menuOpen,
    mediaPaused,
    dragging,
  ]);
 
  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      if (disabled) return;
 
      rootImplProps.onKeyDown?.(event);
 
      if (event.defaultPrevented) return;
 
      const mediaElement = mediaRef.current;
      if (!mediaElement) return;
 
      const isMediaFocused = document.activeElement === mediaElement;
      const isPlayerFocused =
        document.activeElement?.closest('[data-slot="media-player"]') !== null;
 
      if (!isMediaFocused && !isPlayerFocused) return;
 
      switch (event.key.toLowerCase()) {
        case " ":
        case "k":
          event.preventDefault();
          dispatch({
            type: mediaElement.paused
              ? MediaActionTypes.MEDIA_PLAY_REQUEST
              : MediaActionTypes.MEDIA_PAUSE_REQUEST,
          });
          break;
 
        case "f":
          event.preventDefault();
          dispatch({
            type: document.fullscreenElement
              ? MediaActionTypes.MEDIA_EXIT_FULLSCREEN_REQUEST
              : MediaActionTypes.MEDIA_ENTER_FULLSCREEN_REQUEST,
          });
          break;
 
        case "m": {
          event.preventDefault();
          if (isVideo) {
            onVolumeIndicatorTrigger();
          }
          dispatch({
            type: mediaElement.muted
              ? MediaActionTypes.MEDIA_UNMUTE_REQUEST
              : MediaActionTypes.MEDIA_MUTE_REQUEST,
          });
          break;
        }
 
        case "arrowright":
          event.preventDefault();
          if (
            isVideo ||
            (mediaElement instanceof HTMLAudioElement && event.shiftKey)
          ) {
            dispatch({
              type: MediaActionTypes.MEDIA_SEEK_REQUEST,
              detail: Math.min(
                mediaElement.duration,
                mediaElement.currentTime + SEEK_AMOUNT_SHORT,
              ),
            });
          }
          break;
 
        case "arrowleft":
          event.preventDefault();
          if (
            isVideo ||
            (mediaElement instanceof HTMLAudioElement && event.shiftKey)
          ) {
            dispatch({
              type: MediaActionTypes.MEDIA_SEEK_REQUEST,
              detail: Math.max(0, mediaElement.currentTime - SEEK_AMOUNT_SHORT),
            });
          }
          break;
 
        case "arrowup":
          event.preventDefault();
          if (isVideo) {
            onVolumeIndicatorTrigger();
            dispatch({
              type: MediaActionTypes.MEDIA_VOLUME_REQUEST,
              detail: Math.min(1, mediaElement.volume + 0.1),
            });
          }
          break;
 
        case "arrowdown":
          event.preventDefault();
          if (isVideo) {
            onVolumeIndicatorTrigger();
            dispatch({
              type: MediaActionTypes.MEDIA_VOLUME_REQUEST,
              detail: Math.max(0, mediaElement.volume - 0.1),
            });
          }
          break;
 
        case "<": {
          event.preventDefault();
          const currentRate = mediaElement.playbackRate;
          const currentIndex = SPEEDS.indexOf(currentRate);
          const newIndex = Math.max(0, currentIndex - 1);
          const newRate = SPEEDS[newIndex] ?? 1;
          dispatch({
            type: MediaActionTypes.MEDIA_PLAYBACK_RATE_REQUEST,
            detail: newRate,
          });
          break;
        }
 
        case ">": {
          event.preventDefault();
          const currentRate = mediaElement.playbackRate;
          const currentIndex = SPEEDS.indexOf(currentRate);
          const newIndex = Math.min(SPEEDS.length - 1, currentIndex + 1);
          const newRate = SPEEDS[newIndex] ?? 1;
          dispatch({
            type: MediaActionTypes.MEDIA_PLAYBACK_RATE_REQUEST,
            detail: newRate,
          });
          break;
        }
 
        case "c":
          event.preventDefault();
          if (isVideo && mediaElement.textTracks.length > 0) {
            dispatch({
              type: MediaActionTypes.MEDIA_TOGGLE_SUBTITLES_REQUEST,
            });
          }
          break;
 
        case "d": {
          const hasDownload = mediaElement.querySelector(
            '[data-slot="media-player-download"]',
          );
 
          if (!hasDownload) break;
 
          event.preventDefault();
          if (mediaElement.currentSrc) {
            const link = document.createElement("a");
            link.href = mediaElement.currentSrc;
            link.download = "";
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
          }
          break;
        }
 
        case "p": {
          event.preventDefault();
          if (isVideo && "requestPictureInPicture" in mediaElement) {
            const isPip = document.pictureInPictureElement === mediaElement;
            dispatch({
              type: isPip
                ? MediaActionTypes.MEDIA_EXIT_PIP_REQUEST
                : MediaActionTypes.MEDIA_ENTER_PIP_REQUEST,
            });
            if (isPip) {
              document.exitPictureInPicture().catch((error) => {
                onPipError?.(error, "exit");
              });
            } else {
              mediaElement.requestPictureInPicture().catch((error) => {
                onPipError?.(error, "enter");
              });
            }
          }
          break;
        }
 
        case "r": {
          event.preventDefault();
          mediaElement.loop = !mediaElement.loop;
          break;
        }
 
        case "j": {
          event.preventDefault();
          dispatch({
            type: MediaActionTypes.MEDIA_SEEK_REQUEST,
            detail: Math.max(0, mediaElement.currentTime - SEEK_AMOUNT_LONG),
          });
          break;
        }
 
        case "l": {
          event.preventDefault();
          dispatch({
            type: MediaActionTypes.MEDIA_SEEK_REQUEST,
            detail: Math.min(
              mediaElement.duration,
              mediaElement.currentTime + SEEK_AMOUNT_LONG,
            ),
          });
          break;
        }
 
        case "0":
        case "1":
        case "2":
        case "3":
        case "4":
        case "5":
        case "6":
        case "7":
        case "8":
        case "9": {
          event.preventDefault();
          const percent = Number.parseInt(event.key) / 10;
          const seekTime = mediaElement.duration * percent;
          dispatch({
            type: MediaActionTypes.MEDIA_SEEK_REQUEST,
            detail: seekTime,
          });
          break;
        }
 
        case "home": {
          event.preventDefault();
          dispatch({
            type: MediaActionTypes.MEDIA_SEEK_REQUEST,
            detail: 0,
          });
          break;
        }
 
        case "end": {
          event.preventDefault();
          dispatch({
            type: MediaActionTypes.MEDIA_SEEK_REQUEST,
            detail: mediaElement.duration,
          });
          break;
        }
      }
    },
    [
      dispatch,
      rootImplProps.onKeyDown,
      onVolumeIndicatorTrigger,
      onPipError,
      disabled,
      isVideo,
    ],
  );
 
  const onKeyUp = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      rootImplProps.onKeyUp?.(event);
 
      const key = event.key.toLowerCase();
      if (key === "arrowup" || key === "arrowdown" || key === "m") {
        onVolumeIndicatorTrigger();
      }
    },
    [rootImplProps.onKeyUp, onVolumeIndicatorTrigger],
  );
 
  React.useEffect(() => {
    const mediaElement = mediaRef.current;
    if (!mediaElement) return;
 
    if (onPlay) mediaElement.addEventListener("play", onPlay);
    if (onPause) mediaElement.addEventListener("pause", onPause);
    if (onEnded) mediaElement.addEventListener("ended", onEnded);
    if (onTimeUpdate)
      mediaElement.addEventListener("timeupdate", () =>
        onTimeUpdate?.(mediaElement.currentTime),
      );
    if (onVolumeChange)
      mediaElement.addEventListener("volumechange", () => {
        onVolumeChange?.(mediaElement.volume);
        onMuted?.(mediaElement.muted);
      });
    if (onMediaError)
      mediaElement.addEventListener("error", () =>
        onMediaError?.(mediaElement.error),
      );
    if (onFullscreenChange) {
      document.addEventListener("fullscreenchange", () =>
        onFullscreenChange?.(!!document.fullscreenElement),
      );
    }
 
    return () => {
      if (onPlay) mediaElement.removeEventListener("play", onPlay);
      if (onPause) mediaElement.removeEventListener("pause", onPause);
      if (onEnded) mediaElement.removeEventListener("ended", onEnded);
      if (onTimeUpdate)
        mediaElement.removeEventListener("timeupdate", () =>
          onTimeUpdate?.(mediaElement.currentTime),
        );
      if (onVolumeChange)
        mediaElement.removeEventListener("volumechange", () => {
          onVolumeChange?.(mediaElement.volume);
          onMuted?.(mediaElement.muted);
        });
      if (onMediaError)
        mediaElement.removeEventListener("error", () =>
          onMediaError?.(mediaElement.error),
        );
      if (onFullscreenChange) {
        document.removeEventListener("fullscreenchange", () =>
          onFullscreenChange?.(!!document.fullscreenElement),
        );
      }
      if (volumeIndicatorTimeoutRef.current) {
        clearTimeout(volumeIndicatorTimeoutRef.current);
      }
      if (hideControlsTimeoutRef.current) {
        clearTimeout(hideControlsTimeoutRef.current);
      }
    };
  }, [
    onPlay,
    onPause,
    onEnded,
    onTimeUpdate,
    onVolumeChange,
    onMuted,
    onMediaError,
    onFullscreenChange,
  ]);
 
  const contextValue = React.useMemo<MediaPlayerContextValue>(
    () => ({
      mediaId,
      labelId,
      descriptionId,
      dir,
      rootRef,
      mediaRef,
      portalContainer,
      tooltipDelayDuration,
      tooltipSideOffset,
      disabled,
      isVideo,
      withoutTooltip,
    }),
    [
      mediaId,
      labelId,
      descriptionId,
      dir,
      portalContainer,
      tooltipDelayDuration,
      tooltipSideOffset,
      disabled,
      isVideo,
      withoutTooltip,
    ],
  );
 
  const RootPrimitive = asChild ? Slot : "div";
 
  return (
    <MediaPlayerContext.Provider value={contextValue}>
      <RootPrimitive
        aria-labelledby={labelId}
        aria-describedby={descriptionId}
        aria-disabled={disabled}
        data-disabled={disabled ? "" : undefined}
        data-controls-visible={controlsVisible ? "" : undefined}
        data-slot="media-player"
        data-state={isFullscreen ? "fullscreen" : "windowed"}
        dir={dir}
        tabIndex={disabled ? undefined : 0}
        {...rootImplProps}
        ref={composedRef}
        onMouseLeave={onMouseLeave}
        onMouseMove={onMouseMove}
        onKeyDown={onKeyDown}
        onKeyUp={onKeyUp}
        className={cn(
          "dark relative isolate flex flex-col overflow-hidden rounded-lg bg-background outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_video]:relative [&_video]:object-contain",
          "data-[state=fullscreen]:[&_video]:size-full [:fullscreen_&]:flex [:fullscreen_&]:h-full [:fullscreen_&]:max-h-screen [:fullscreen_&]:flex-col [:fullscreen_&]:justify-between",
          "[&_[data-slider]::before]:-top-4 [&_[data-slider]::before]:-bottom-2 [&_[data-slider]::before]:absolute [&_[data-slider]::before]:inset-x-0 [&_[data-slider]::before]:z-10 [&_[data-slider]::before]:h-8 [&_[data-slider]::before]:cursor-pointer [&_[data-slider]::before]:content-[''] [&_[data-slider]]:relative",
          "[&_video::-webkit-media-text-track-display]:top-auto! [&_video::-webkit-media-text-track-display]:bottom-[4%]! [&_video::-webkit-media-text-track-display]:mb-0! data-[state=fullscreen]:data-[controls-visible]:[&_video::-webkit-media-text-track-display]:bottom-[9%]! data-[controls-visible]:[&_video::-webkit-media-text-track-display]:bottom-[13%]! data-[state=fullscreen]:[&_video::-webkit-media-text-track-display]:bottom-[7%]!",
          className,
        )}
      >
        <span id={labelId} className="sr-only">
          {label ?? "Media player"}
        </span>
        <span id={descriptionId} className="sr-only">
          {isVideo
            ? "Video player with custom controls for playback, volume, seeking, and more. Use space bar to play/pause, arrow keys (←/→) to seek, and arrow keys (↑/↓) to adjust volume."
            : "Audio player with custom controls for playback, volume, seeking, and more. Use space bar to play/pause, Shift + arrow keys (←/→) to seek, and arrow keys (↑/↓) to adjust volume."}
        </span>
        {children}
        <MediaPlayerVolumeIndicator />
      </RootPrimitive>
    </MediaPlayerContext.Provider>
  );
}
 
interface MediaPlayerVideoProps extends React.ComponentProps<"video"> {
  asChild?: boolean;
}
 
function MediaPlayerVideo(props: MediaPlayerVideoProps) {
  const { asChild, ref, ...videoProps } = props;
 
  const context = useMediaPlayerContext("MediaPlayerVideo");
  const dispatch = useMediaDispatch();
  const mediaRefCallback = useMediaRef();
  const composedRef = useComposedRefs(ref, context.mediaRef, mediaRefCallback);
 
  const onPlayToggle = React.useCallback(
    (event: React.MouseEvent<HTMLVideoElement>) => {
      props.onClick?.(event);
 
      if (event.defaultPrevented) return;
 
      const mediaElement = event.currentTarget;
      if (!mediaElement) return;
 
      dispatch({
        type: mediaElement.paused
          ? MediaActionTypes.MEDIA_PLAY_REQUEST
          : MediaActionTypes.MEDIA_PAUSE_REQUEST,
      });
    },
    [dispatch, props.onClick],
  );
 
  const VideoPrimitive = asChild ? Slot : "video";
 
  return (
    <VideoPrimitive
      aria-describedby={context.descriptionId}
      aria-labelledby={context.labelId}
      data-slot="media-player-video"
      {...videoProps}
      id={context.mediaId}
      ref={composedRef}
      onClick={onPlayToggle}
    />
  );
}
 
interface MediaPlayerAudioProps extends React.ComponentProps<"audio"> {
  asChild?: boolean;
}
 
function MediaPlayerAudio(props: MediaPlayerAudioProps) {
  const { asChild, ref, ...audioProps } = props;
 
  const context = useMediaPlayerContext("MediaPlayerAudio");
  const mediaRefCallback = useMediaRef();
  const composedRef = useComposedRefs(ref, context.mediaRef, mediaRefCallback);
 
  const AudioPrimitive = asChild ? Slot : "audio";
 
  return (
    <AudioPrimitive
      aria-describedby={context.descriptionId}
      aria-labelledby={context.labelId}
      data-slot="media-player-audio"
      {...audioProps}
      id={context.mediaId}
      ref={composedRef}
    />
  );
}
 
interface MediaPlayerControlsProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
function MediaPlayerControls(props: MediaPlayerControlsProps) {
  const { asChild, className, ...controlsProps } = props;
 
  const context = useMediaPlayerContext("MediaPlayerControls");
  const isFullscreen = useMediaSelector(
    (state) => state.mediaIsFullscreen ?? false,
  );
  const controlsVisible = useStoreSelector((state) => state.controlsVisible);
 
  const ControlsPrimitive = asChild ? Slot : "div";
 
  return (
    <ControlsPrimitive
      data-disabled={context.disabled ? "" : undefined}
      data-slot="media-player-controls"
      data-state={isFullscreen ? "fullscreen" : "windowed"}
      data-visible={controlsVisible ? "" : undefined}
      dir={context.dir}
      className={cn(
        "dark pointer-events-none absolute right-0 bottom-0 left-0 z-50 flex items-center gap-2 px-4 py-3 opacity-0 transition-opacity duration-200 data-[visible]:pointer-events-auto data-[visible]:opacity-100 [:fullscreen_&]:px-6 [:fullscreen_&]:py-4",
        className,
      )}
      {...controlsProps}
    />
  );
}
 
interface MediaPlayerLoadingProps extends React.ComponentProps<"div"> {
  delayMs?: number;
  asChild?: boolean;
}
 
function MediaPlayerLoading(props: MediaPlayerLoadingProps) {
  const {
    delayMs = LOADING_DELAY_MS,
    asChild,
    className,
    children,
    ...loadingProps
  } = props;
 
  const isLoading = useMediaSelector((state) => state.mediaLoading ?? false);
  const isPaused = useMediaSelector((state) => state.mediaPaused ?? true);
  const hasPlayed = useMediaSelector((state) => state.mediaHasPlayed ?? false);
 
  const shouldShowLoading = isLoading && !isPaused;
  const shouldUseDelay = hasPlayed && shouldShowLoading;
  const loadingDelayMs = shouldUseDelay ? delayMs : 0;
 
  const [shouldRender, setShouldRender] = React.useState(false);
  const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
 
  React.useEffect(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
 
    if (shouldShowLoading) {
      if (loadingDelayMs > 0) {
        timeoutRef.current = setTimeout(() => {
          setShouldRender(true);
          timeoutRef.current = null;
        }, loadingDelayMs);
      } else {
        setShouldRender(true);
      }
    } else {
      setShouldRender(false);
    }
 
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
    };
  }, [shouldShowLoading, loadingDelayMs]);
 
  if (!shouldRender) return null;
 
  const LoadingPrimitive = asChild ? Slot : "div";
 
  return (
    <LoadingPrimitive
      role="status"
      aria-live="polite"
      data-slot="media-player-loading"
      {...loadingProps}
      className={cn(
        "fade-in-0 zoom-in-95 pointer-events-none absolute inset-0 z-50 flex animate-in items-center justify-center duration-200",
        className,
      )}
    >
      <Loader2Icon className="size-20 animate-spin stroke-[.0938rem] text-primary" />
    </LoadingPrimitive>
  );
}
 
interface MediaPlayerErrorProps extends React.ComponentProps<"div"> {
  error?: MediaError | null;
  label?: string;
  description?: string;
  onRetry?: () => void;
  onReload?: () => void;
  asChild?: boolean;
}
 
function MediaPlayerError(props: MediaPlayerErrorProps) {
  const {
    error: errorProp,
    label,
    description,
    onRetry: onRetryProp,
    onReload: onReloadProp,
    asChild,
    className,
    children,
    ...errorProps
  } = props;
 
  const context = useMediaPlayerContext("MediaPlayerError");
  const isFullscreen = useMediaSelector(
    (state) => state.mediaIsFullscreen ?? false,
  );
  const mediaError = useMediaSelector((state) => state.mediaError);
 
  const error = errorProp ?? mediaError;
 
  const labelId = React.useId();
  const descriptionId = React.useId();
 
  const [actionState, setActionState] = React.useState<{
    retryPending: boolean;
    reloadPending: boolean;
  }>({
    retryPending: false,
    reloadPending: false,
  });
 
  const onRetry = React.useCallback(() => {
    setActionState((prev) => ({ ...prev, retryPending: true }));
 
    requestAnimationFrame(() => {
      const mediaElement = context.mediaRef.current;
      if (!mediaElement) {
        setActionState((prev) => ({ ...prev, retryPending: false }));
        return;
      }
 
      if (onRetryProp) {
        onRetryProp();
      } else {
        const currentSrc = mediaElement.currentSrc ?? mediaElement.src;
        if (currentSrc) {
          mediaElement.load();
        }
      }
 
      setActionState((prev) => ({ ...prev, retryPending: false }));
    });
  }, [context.mediaRef, onRetryProp]);
 
  const onReload = React.useCallback(() => {
    setActionState((prev) => ({ ...prev, reloadPending: true }));
 
    requestAnimationFrame(() => {
      if (onReloadProp) {
        onReloadProp();
      } else {
        window.location.reload();
      }
    });
  }, [onReloadProp]);
 
  const errorLabel = React.useMemo(() => {
    if (label) return label;
 
    if (!error) return "Playback Error";
 
    const labelMap: Record<number, string> = {
      [MediaError.MEDIA_ERR_ABORTED]: "Playback Interrupted",
      [MediaError.MEDIA_ERR_NETWORK]: "Connection Problem",
      [MediaError.MEDIA_ERR_DECODE]: "Media Error",
      [MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED]: "Unsupported Format",
    };
 
    return labelMap[error.code] ?? "Playback Error";
  }, [label, error]);
 
  const errorDescription = React.useMemo(() => {
    if (description) return description;
 
    if (!error) return "An unknown error occurred";
 
    const descriptionMap: Record<number, string> = {
      [MediaError.MEDIA_ERR_ABORTED]: "Media playback was aborted",
      [MediaError.MEDIA_ERR_NETWORK]:
        "A network error occurred while loading the media",
      [MediaError.MEDIA_ERR_DECODE]:
        "An error occurred while decoding the media",
      [MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED]:
        "The media format is not supported",
    };
 
    return descriptionMap[error.code] ?? "An unknown error occurred";
  }, [description, error]);
 
  if (!error) return null;
 
  const ErrorPrimitive = asChild ? Slot : "div";
 
  return (
    <ErrorPrimitive
      role="alert"
      aria-describedby={descriptionId}
      aria-labelledby={labelId}
      aria-live="assertive"
      data-slot="media-player-error"
      data-state={isFullscreen ? "fullscreen" : "windowed"}
      {...errorProps}
      className={cn(
        "pointer-events-auto absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80 text-white backdrop-blur-sm",
        className,
      )}
    >
      {children ?? (
        <div className="flex max-w-md flex-col items-center gap-4 px-6 py-8 text-center">
          <AlertTriangleIcon className="size-12 text-destructive" />
          <div className="flex flex-col gap-px text-center">
            <h3 className="font-semibold text-xl tracking-tight">
              {errorLabel}
            </h3>
            <p className="text-balance text-muted-foreground text-sm leading-relaxed">
              {errorDescription}
            </p>
          </div>
          <div className="flex items-center gap-2">
            <Button
              variant="secondary"
              size="sm"
              onClick={onRetry}
              disabled={actionState.retryPending}
            >
              {actionState.retryPending ? (
                <Loader2Icon className="animate-spin" />
              ) : (
                <RefreshCcwIcon />
              )}
              Try again
            </Button>
            <Button
              variant="outline"
              size="sm"
              onClick={onReload}
              disabled={actionState.reloadPending}
            >
              {actionState.reloadPending ? (
                <Loader2Icon className="animate-spin" />
              ) : (
                <RotateCcwIcon />
              )}
              Reload page
            </Button>
          </div>
        </div>
      )}
    </ErrorPrimitive>
  );
}
 
interface MediaPlayerVolumeIndicatorProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
function MediaPlayerVolumeIndicator(props: MediaPlayerVolumeIndicatorProps) {
  const { asChild, className, ...indicatorProps } = props;
 
  const mediaVolume = useMediaSelector((state) => state.mediaVolume ?? 1);
  const mediaMuted = useMediaSelector((state) => state.mediaMuted ?? false);
  const mediaVolumeLevel = useMediaSelector(
    (state) => state.mediaVolumeLevel ?? "high",
  );
  const volumeIndicatorVisible = useStoreSelector(
    (state) => state.volumeIndicatorVisible,
  );
 
  if (!volumeIndicatorVisible) return null;
 
  const effectiveVolume = mediaMuted ? 0 : mediaVolume;
  const volumePercentage = Math.round(effectiveVolume * 100);
  const barCount = 10;
  const activeBarCount = Math.ceil(effectiveVolume * barCount);
 
  const VolumeIndicatorPrimitive = asChild ? Slot : "div";
 
  return (
    <VolumeIndicatorPrimitive
      role="status"
      aria-live="polite"
      aria-label={`Volume ${mediaMuted ? "muted" : `${volumePercentage}%`}`}
      data-slot="media-player-volume-indicator"
      {...indicatorProps}
      className={cn(
        "pointer-events-none absolute inset-0 z-50 flex items-center justify-center",
        className,
      )}
    >
      <div className="fade-in-0 zoom-in-95 flex animate-in flex-col items-center gap-3 rounded-lg bg-black/30 px-6 py-4 text-white backdrop-blur-xs duration-200">
        <div className="flex items-center gap-2">
          {mediaVolumeLevel === "off" || mediaMuted ? (
            <VolumeXIcon className="size-6" />
          ) : mediaVolumeLevel === "high" ? (
            <Volume2Icon className="size-6" />
          ) : (
            <Volume1Icon className="size-6" />
          )}
          <span className="font-medium text-sm tabular-nums">
            {mediaMuted ? "Muted" : `${volumePercentage}%`}
          </span>
        </div>
        <div className="flex items-center gap-1">
          {Array.from({ length: barCount }, (_, index) => (
            <div
              key={index}
              className={cn(
                "w-1.5 rounded-full transition-all duration-150",
                index < activeBarCount && !mediaMuted
                  ? "scale-100 bg-white"
                  : "scale-90 bg-white/30",
              )}
              style={{
                height: `${12 + index * 2}px`,
                animationDelay: `${index * 50}ms`,
              }}
            />
          ))}
        </div>
      </div>
    </VolumeIndicatorPrimitive>
  );
}
 
interface MediaPlayerControlsOverlayProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
function MediaPlayerControlsOverlay(props: MediaPlayerControlsOverlayProps) {
  const { asChild, className, ...overlayProps } = props;
 
  const isFullscreen = useMediaSelector(
    (state) => state.mediaIsFullscreen ?? false,
  );
  const controlsVisible = useStoreSelector((state) => state.controlsVisible);
 
  const OverlayPrimitive = asChild ? Slot : "div";
 
  return (
    <OverlayPrimitive
      data-slot="media-player-controls-overlay"
      data-state={isFullscreen ? "fullscreen" : "windowed"}
      data-visible={controlsVisible ? "" : undefined}
      {...overlayProps}
      className={cn(
        "-z-10 pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 transition-opacity duration-200 data-[visible]:opacity-100",
        className,
      )}
    />
  );
}
 
interface MediaPlayerPlayProps extends React.ComponentProps<typeof Button> {}
 
function MediaPlayerPlay(props: MediaPlayerPlayProps) {
  const { asChild, children, className, disabled, ...playButtonProps } = props;
 
  const context = useMediaPlayerContext("MediaPlayerPlay");
  const dispatch = useMediaDispatch();
  const mediaPaused = useMediaSelector((state) => state.mediaPaused ?? true);
 
  const isDisabled = disabled || context.disabled;
 
  const onPlayToggle = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      props.onClick?.(event);
 
      if (event.defaultPrevented) return;
 
      dispatch({
        type: mediaPaused
          ? MediaActionTypes.MEDIA_PLAY_REQUEST
          : MediaActionTypes.MEDIA_PAUSE_REQUEST,
      });
    },
    [dispatch, props.onClick, mediaPaused],
  );
 
  return (
    <MediaPlayerTooltip
      tooltip={mediaPaused ? "Play" : "Pause"}
      shortcut="Space"
    >
      <Button
        type="button"
        aria-controls={context.mediaId}
        aria-label={mediaPaused ? "Play" : "Pause"}
        aria-pressed={!mediaPaused}
        data-disabled={isDisabled ? "" : undefined}
        data-slot="media-player-play-button"
        data-state={mediaPaused ? "off" : "on"}
        disabled={isDisabled}
        {...playButtonProps}
        variant="ghost"
        size="icon"
        className={cn(
          "size-8 [&_svg:not([class*='fill-'])]:fill-current",
          className,
        )}
        onClick={onPlayToggle}
      >
        {children ?? (mediaPaused ? <PlayIcon /> : <PauseIcon />)}
      </Button>
    </MediaPlayerTooltip>
  );
}
 
interface MediaPlayerSeekBackwardProps
  extends React.ComponentProps<typeof Button> {
  seconds?: number;
}
 
function MediaPlayerSeekBackward(props: MediaPlayerSeekBackwardProps) {
  const {
    seconds = SEEK_AMOUNT_SHORT,
    asChild,
    children,
    className,
    disabled,
    ...seekBackwardProps
  } = props;
 
  const context = useMediaPlayerContext("MediaPlayerSeekBackward");
  const dispatch = useMediaDispatch();
  const mediaCurrentTime = useMediaSelector(
    (state) => state.mediaCurrentTime ?? 0,
  );
 
  const isDisabled = disabled || context.disabled;
 
  const onSeekBackward = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      props.onClick?.(event);
 
      if (event.defaultPrevented) return;
 
      dispatch({
        type: MediaActionTypes.MEDIA_SEEK_REQUEST,
        detail: Math.max(0, mediaCurrentTime - seconds),
      });
    },
    [dispatch, props.onClick, mediaCurrentTime, seconds],
  );
 
  return (
    <MediaPlayerTooltip
      tooltip={`Back ${seconds}s`}
      shortcut={context.isVideo ? ["←"] : ["Shift ←"]}
    >
      <Button
        type="button"
        aria-controls={context.mediaId}
        aria-label={`Back ${seconds} seconds`}
        data-disabled={isDisabled ? "" : undefined}
        data-slot="media-player-seek-backward"
        disabled={isDisabled}
        {...seekBackwardProps}
        variant="ghost"
        size="icon"
        className={cn("size-8", className)}
        onClick={onSeekBackward}
      >
        {children ?? <RewindIcon />}
      </Button>
    </MediaPlayerTooltip>
  );
}
 
interface MediaPlayerSeekForwardProps
  extends React.ComponentProps<typeof Button> {
  seconds?: number;
}
 
function MediaPlayerSeekForward(props: MediaPlayerSeekForwardProps) {
  const {
    seconds = SEEK_AMOUNT_LONG,
    asChild,
    children,
    className,
    disabled,
    ...seekForwardProps
  } = props;
 
  const context = useMediaPlayerContext("MediaPlayerSeekForward");
  const dispatch = useMediaDispatch();
  const mediaCurrentTime = useMediaSelector(
    (state) => state.mediaCurrentTime ?? 0,
  );
  const [, seekableEnd] = useMediaSelector(
    (state) => state.mediaSeekable ?? [0, 0],
  );
  const isDisabled = disabled || context.disabled;
 
  const onSeekForward = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      props.onClick?.(event);
 
      if (event.defaultPrevented) return;
 
      dispatch({
        type: MediaActionTypes.MEDIA_SEEK_REQUEST,
        detail: Math.min(
          seekableEnd ?? Number.POSITIVE_INFINITY,
          mediaCurrentTime + seconds,
        ),
      });
    },
    [dispatch, props.onClick, mediaCurrentTime, seekableEnd, seconds],
  );
 
  return (
    <MediaPlayerTooltip
      tooltip={`Forward ${seconds}s`}
      shortcut={context.isVideo ? ["→"] : ["Shift →"]}
    >
      <Button
        type="button"
        aria-controls={context.mediaId}
        aria-label={`Forward ${seconds} seconds`}
        data-disabled={isDisabled ? "" : undefined}
        data-slot="media-player-seek-forward"
        disabled={isDisabled}
        {...seekForwardProps}
        variant="ghost"
        size="icon"
        className={cn("size-8", className)}
        onClick={onSeekForward}
      >
        {children ?? <FastForwardIcon />}
      </Button>
    </MediaPlayerTooltip>
  );
}
 
interface SeekState {
  isHovering: boolean;
  pendingSeekTime: number | null;
  hasInitialPosition: boolean;
}
 
interface MediaPlayerSeekProps
  extends React.ComponentProps<typeof SliderPrimitive.Root> {
  withTime?: boolean;
  withoutChapter?: boolean;
  withoutTooltip?: boolean;
  tooltipThumbnailSrc?: string | ((time: number) => string);
  tooltipTimeVariant?: "current" | "progress";
  tooltipSideOffset?: number;
  tooltipCollisionBoundary?: Element | Element[];
  tooltipCollisionPadding?:
    | number
    | Partial<Record<"top" | "right" | "bottom" | "left", number>>;
}
 
function MediaPlayerSeek(props: MediaPlayerSeekProps) {
  const {
    withTime = false,
    withoutChapter = false,
    withoutTooltip = false,
    tooltipTimeVariant = "current",
    tooltipThumbnailSrc,
    tooltipSideOffset,
    tooltipCollisionPadding = SEEK_COLLISION_PADDING,
    tooltipCollisionBoundary,
    className,
    disabled,
    ...seekProps
  } = props;
 
  const context = useMediaPlayerContext(SEEK_NAME);
  const store = useStoreContext(SEEK_NAME);
  const dispatch = useMediaDispatch();
  const mediaCurrentTime = useMediaSelector(
    (state) => state.mediaCurrentTime ?? 0,
  );
  const [seekableStart = 0, seekableEnd = 0] = useMediaSelector(
    (state) => state.mediaSeekable ?? [0, 0],
  );
  const mediaBuffered = useMediaSelector((state) => state.mediaBuffered ?? []);
  const mediaEnded = useMediaSelector((state) => state.mediaEnded ?? false);
 
  const chapterCues = useMediaSelector(
    (state) => state.mediaChaptersCues ?? [],
  );
  const mediaPreviewTime = useMediaSelector((state) => state.mediaPreviewTime);
  const mediaPreviewImage = useMediaSelector(
    (state) => state.mediaPreviewImage,
  );
  const mediaPreviewCoords = useMediaSelector(
    (state) => state.mediaPreviewCoords,
  );
 
  const seekRef = React.useRef<HTMLDivElement>(null);
  const tooltipRef = React.useRef<HTMLDivElement>(null);
  const justCommittedRef = React.useRef<boolean>(false);
 
  const hoverTimeRef = React.useRef(0);
  const tooltipXRef = React.useRef(0);
  const tooltipYRef = React.useRef(0);
  const seekRectRef = React.useRef<DOMRect | null>(null);
  const collisionDataRef = React.useRef<{
    padding: { top: number; right: number; bottom: number; left: number };
    boundaries: Element[];
  } | null>(null);
 
  const [seekState, setSeekState] = React.useState<SeekState>({
    isHovering: false,
    pendingSeekTime: null,
    hasInitialPosition: false,
  });
 
  const rafIdRef = React.useRef<number | null>(null);
  const seekThrottleRef = React.useRef<number | null>(null);
  const hoverTimeoutRef = React.useRef<number | null>(null);
  const lastPointerXRef = React.useRef<number>(0);
  const previewDebounceRef = React.useRef<number | null>(null);
 
  const timeCache = React.useRef<Map<number, string>>(new Map());
 
  const displayValue = seekState.pendingSeekTime ?? mediaCurrentTime;
 
  const isDisabled = disabled || context.disabled;
  const tooltipDisabled =
    withoutTooltip || context.withoutTooltip || store.getState().menuOpen;
 
  const currentTooltipSideOffset =
    tooltipSideOffset ?? context.tooltipSideOffset;
 
  const getCachedTime = React.useCallback((time: number, duration: number) => {
    const roundedTime = Math.floor(time);
    const key = roundedTime + duration * 10000;
 
    if (timeCache.current.has(key)) {
      return timeCache.current.get(key) as string;
    }
 
    const formatted = timeUtils.formatTime(time, duration);
    timeCache.current.set(key, formatted);
 
    if (timeCache.current.size > 100) {
      timeCache.current.clear();
    }
 
    return formatted;
  }, []);
 
  const currentTime = getCachedTime(displayValue, seekableEnd);
  const duration = getCachedTime(seekableEnd, seekableEnd);
  const remainingTime = getCachedTime(seekableEnd - displayValue, seekableEnd);
 
  const onCollisionDataUpdate = React.useCallback(() => {
    if (collisionDataRef.current) return collisionDataRef.current;
 
    const padding =
      typeof tooltipCollisionPadding === "number"
        ? {
            top: tooltipCollisionPadding,
            right: tooltipCollisionPadding,
            bottom: tooltipCollisionPadding,
            left: tooltipCollisionPadding,
          }
        : { top: 0, right: 0, bottom: 0, left: 0, ...tooltipCollisionPadding };
 
    const boundaries = tooltipCollisionBoundary
      ? Array.isArray(tooltipCollisionBoundary)
        ? tooltipCollisionBoundary
        : [tooltipCollisionBoundary]
      : ([context.rootRef.current].filter(Boolean) as Element[]);
 
    collisionDataRef.current = { padding, boundaries };
    return collisionDataRef.current;
  }, [tooltipCollisionPadding, tooltipCollisionBoundary, context.rootRef]);
 
  const getCurrentChapterCue = React.useCallback(
    (time: number) => {
      if (withoutChapter || chapterCues.length === 0) return null;
      return chapterCues.find((c) => time >= c.startTime && time < c.endTime);
    },
    [chapterCues, withoutChapter],
  );
 
  const getThumbnail = React.useCallback(
    (time: number) => {
      if (tooltipDisabled) return null;
 
      if (tooltipThumbnailSrc) {
        const src =
          typeof tooltipThumbnailSrc === "function"
            ? tooltipThumbnailSrc(time)
            : tooltipThumbnailSrc;
        return { src, coords: null };
      }
 
      if (
        mediaPreviewTime !== undefined &&
        Math.abs(time - mediaPreviewTime) < 0.1 &&
        mediaPreviewImage
      ) {
        return {
          src: mediaPreviewImage,
          coords: mediaPreviewCoords ?? null,
        };
      }
 
      return null;
    },
    [
      tooltipThumbnailSrc,
      mediaPreviewTime,
      mediaPreviewImage,
      mediaPreviewCoords,
      tooltipDisabled,
    ],
  );
 
  const onPreviewUpdate = React.useCallback(
    (time: number) => {
      if (tooltipDisabled) return;
 
      if (previewDebounceRef.current) {
        cancelAnimationFrame(previewDebounceRef.current);
      }
 
      previewDebounceRef.current = requestAnimationFrame(() => {
        dispatch({
          type: MediaActionTypes.MEDIA_PREVIEW_REQUEST,
          detail: time,
        });
        previewDebounceRef.current = null;
      });
    },
    [dispatch, tooltipDisabled],
  );
 
  const onTooltipPositionUpdate = React.useCallback(
    (clientX: number) => {
      if (!seekRef.current) return;
 
      const tooltipWidth =
        tooltipRef.current?.offsetWidth ?? SEEK_TOOLTIP_WIDTH_FALLBACK;
 
      let x = clientX;
      const y = seekRectRef.current?.top ?? 0;
 
      const collisionData = onCollisionDataUpdate();
      const halfTooltipWidth = tooltipWidth / 2;
 
      let minLeft = 0;
      let maxRight = window.innerWidth;
 
      for (const boundary of collisionData.boundaries) {
        const boundaryRect = boundary.getBoundingClientRect();
        minLeft = Math.max(
          minLeft,
          boundaryRect.left + collisionData.padding.left,
        );
        maxRight = Math.min(
          maxRight,
          boundaryRect.right - collisionData.padding.right,
        );
      }
 
      if (x - halfTooltipWidth < minLeft) {
        x = minLeft + halfTooltipWidth;
      } else if (x + halfTooltipWidth > maxRight) {
        x = maxRight - halfTooltipWidth;
      }
 
      const viewportPadding = SEEK_COLLISION_PADDING;
      if (x - halfTooltipWidth < viewportPadding) {
        x = viewportPadding + halfTooltipWidth;
      } else if (x + halfTooltipWidth > window.innerWidth - viewportPadding) {
        x = window.innerWidth - viewportPadding - halfTooltipWidth;
      }
 
      tooltipXRef.current = x;
      tooltipYRef.current = y;
 
      if (tooltipRef.current) {
        tooltipRef.current.style.setProperty(SEEK_TOOLTIP_X, `${x}px`);
        tooltipRef.current.style.setProperty(SEEK_TOOLTIP_Y, `${y}px`);
      }
 
      if (!seekState.hasInitialPosition) {
        setSeekState((prev) => ({ ...prev, hasInitialPosition: true }));
      }
    },
    [onCollisionDataUpdate, seekState.hasInitialPosition],
  );
 
  const onHoverProgressUpdate = React.useCallback(() => {
    if (!seekRef.current || seekableEnd <= 0) return;
 
    const hoverPercent = Math.min(
      100,
      (hoverTimeRef.current / seekableEnd) * 100,
    );
    seekRef.current.style.setProperty(
      SEEK_HOVER_PERCENT,
      `${hoverPercent.toFixed(4)}%`,
    );
  }, [seekableEnd]);
 
  React.useEffect(() => {
    if (seekState.pendingSeekTime !== null) {
      const diff = Math.abs(mediaCurrentTime - seekState.pendingSeekTime);
      if (diff < 0.5) {
        setSeekState((prev) => ({ ...prev, pendingSeekTime: null }));
      }
    }
  }, [mediaCurrentTime, seekState.pendingSeekTime]);
 
  React.useEffect(() => {
    if (!seekState.isHovering || tooltipDisabled) return;
 
    function onScroll() {
      setSeekState((prev) => ({
        ...prev,
        isHovering: false,
        hasInitialPosition: false,
      }));
      dispatch({
        type: MediaActionTypes.MEDIA_PREVIEW_REQUEST,
        detail: undefined,
      });
    }
 
    document.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      document.removeEventListener("scroll", onScroll);
    };
  }, [dispatch, seekState.isHovering, tooltipDisabled]);
 
  const bufferedProgress = React.useMemo(() => {
    if (mediaBuffered.length === 0 || seekableEnd <= 0) return 0;
 
    if (mediaEnded) return 1;
 
    const containingRange = mediaBuffered.find(
      ([start, end]) => start <= mediaCurrentTime && mediaCurrentTime <= end,
    );
 
    if (containingRange) {
      return Math.min(1, containingRange[1] / seekableEnd);
    }
 
    return Math.min(1, seekableStart / seekableEnd);
  }, [mediaBuffered, mediaCurrentTime, seekableEnd, mediaEnded, seekableStart]);
 
  const onPointerEnter = React.useCallback(() => {
    if (seekRef.current) {
      seekRectRef.current = seekRef.current.getBoundingClientRect();
    }
 
    collisionDataRef.current = null;
 
    if (seekableEnd > 0) {
      if (hoverTimeoutRef.current) {
        clearTimeout(hoverTimeoutRef.current);
      }
 
      const delay = justCommittedRef.current ? 16 : 50;
 
      if (!tooltipDisabled) {
        if (lastPointerXRef.current && seekRectRef.current) {
          const clientX = Math.max(
            seekRectRef.current.left,
            Math.min(lastPointerXRef.current, seekRectRef.current.right),
          );
 
          onTooltipPositionUpdate(clientX);
 
          requestAnimationFrame(() => {
            hoverTimeoutRef.current = window.setTimeout(() => {
              setSeekState((prev) => ({ ...prev, isHovering: true }));
            }, delay);
          });
        } else {
          hoverTimeoutRef.current = window.setTimeout(() => {
            setSeekState((prev) => ({ ...prev, isHovering: true }));
          }, delay);
        }
      } else {
        setSeekState((prev) => ({ ...prev, isHovering: true }));
      }
    }
  }, [seekableEnd, onTooltipPositionUpdate, tooltipDisabled]);
 
  const onPointerLeave = React.useCallback(() => {
    if (hoverTimeoutRef.current) {
      clearTimeout(hoverTimeoutRef.current);
      hoverTimeoutRef.current = null;
    }
    if (rafIdRef.current) {
      cancelAnimationFrame(rafIdRef.current);
      rafIdRef.current = null;
    }
    if (previewDebounceRef.current) {
      cancelAnimationFrame(previewDebounceRef.current);
      previewDebounceRef.current = null;
    }
 
    setSeekState((prev) => ({
      ...prev,
      isHovering: false,
      hasInitialPosition: false,
    }));
 
    justCommittedRef.current = false;
    seekRectRef.current = null;
    collisionDataRef.current = null;
 
    if (!tooltipDisabled) {
      dispatch({
        type: MediaActionTypes.MEDIA_PREVIEW_REQUEST,
        detail: undefined,
      });
    }
  }, [dispatch, tooltipDisabled]);
 
  const onPointerMove = React.useCallback(
    (event: React.PointerEvent<HTMLDivElement>) => {
      if (seekableEnd <= 0) return;
 
      if (!seekRectRef.current && seekRef.current) {
        seekRectRef.current = seekRef.current.getBoundingClientRect();
      }
 
      if (!seekRectRef.current) return;
 
      lastPointerXRef.current = event.clientX;
 
      if (rafIdRef.current) {
        cancelAnimationFrame(rafIdRef.current);
      }
 
      rafIdRef.current = requestAnimationFrame(() => {
        const wasJustCommitted = justCommittedRef.current;
        if (wasJustCommitted) {
          justCommittedRef.current = false;
        }
 
        const seekRect = seekRectRef.current;
        if (!seekRect) {
          rafIdRef.current = null;
          return;
        }
 
        const clientX = lastPointerXRef.current;
        const offsetXOnSeekBar = Math.max(
          0,
          Math.min(clientX - seekRect.left, seekRect.width),
        );
        const relativeX = offsetXOnSeekBar / seekRect.width;
        const calculatedHoverTime = relativeX * seekableEnd;
 
        hoverTimeRef.current = calculatedHoverTime;
 
        onHoverProgressUpdate();
 
        const wasHovering = seekState.isHovering;
        const isCurrentlyHovering =
          clientX >= seekRect.left && clientX <= seekRect.right;
 
        if (!wasHovering && isCurrentlyHovering) {
          setSeekState((prev) => ({ ...prev, isHovering: true }));
        }
 
        if (!tooltipDisabled) {
          onPreviewUpdate(calculatedHoverTime);
 
          if (isCurrentlyHovering) {
            onTooltipPositionUpdate(clientX);
          }
        }
 
        rafIdRef.current = null;
      });
    },
    [
      onPreviewUpdate,
      onTooltipPositionUpdate,
      onHoverProgressUpdate,
      seekableEnd,
      seekState.isHovering,
      tooltipDisabled,
    ],
  );
 
  const onSeek = React.useCallback(
    (value: number[]) => {
      const time = value[0] ?? 0;
 
      setSeekState((prev) => ({ ...prev, pendingSeekTime: time }));
      store.setState("dragging", true);
 
      if (seekThrottleRef.current) {
        cancelAnimationFrame(seekThrottleRef.current);
      }
 
      seekThrottleRef.current = requestAnimationFrame(() => {
        dispatch({
          type: MediaActionTypes.MEDIA_SEEK_REQUEST,
          detail: time,
        });
        seekThrottleRef.current = null;
      });
    },
    [dispatch, store.setState],
  );
 
  const onSeekCommit = React.useCallback(
    (value: number[]) => {
      const time = value[0] ?? 0;
 
      if (seekThrottleRef.current) {
        cancelAnimationFrame(seekThrottleRef.current);
        seekThrottleRef.current = null;
      }
 
      if (hoverTimeoutRef.current) {
        clearTimeout(hoverTimeoutRef.current);
        hoverTimeoutRef.current = null;
      }
      if (rafIdRef.current) {
        cancelAnimationFrame(rafIdRef.current);
        rafIdRef.current = null;
      }
      if (previewDebounceRef.current) {
        cancelAnimationFrame(previewDebounceRef.current);
        previewDebounceRef.current = null;
      }
 
      setSeekState((prev) => ({
        ...prev,
        pendingSeekTime: time,
        isHovering: false,
        hasInitialPosition: false,
      }));
 
      justCommittedRef.current = true;
      collisionDataRef.current = null;
      store.setState("dragging", false);
 
      dispatch({
        type: MediaActionTypes.MEDIA_SEEK_REQUEST,
        detail: time,
      });
 
      dispatch({
        type: MediaActionTypes.MEDIA_PREVIEW_REQUEST,
        detail: undefined,
      });
    },
    [dispatch, store.setState],
  );
 
  React.useEffect(() => {
    return () => {
      if (seekThrottleRef.current) {
        cancelAnimationFrame(seekThrottleRef.current);
      }
      if (hoverTimeoutRef.current) {
        clearTimeout(hoverTimeoutRef.current);
      }
      if (rafIdRef.current) {
        cancelAnimationFrame(rafIdRef.current);
      }
      if (previewDebounceRef.current) {
        cancelAnimationFrame(previewDebounceRef.current);
      }
    };
  }, []);
 
  const currentChapterCue = getCurrentChapterCue(hoverTimeRef.current);
  const thumbnail = getThumbnail(hoverTimeRef.current);
  const hoverTime = getCachedTime(hoverTimeRef.current, seekableEnd);
 
  const chapterSeparators = React.useMemo(() => {
    if (withoutChapter || chapterCues.length <= 1 || seekableEnd <= 0) {
      return null;
    }
 
    return chapterCues.slice(1).map((chapterCue, index) => {
      const position = (chapterCue.startTime / seekableEnd) * 100;
 
      return (
        <div
          key={`chapter-${index}-${chapterCue.startTime}`}
          role="presentation"
          aria-hidden="true"
          data-slot="media-player-seek-chapter-separator"
          className="absolute top-0 h-full bg-zinc-50 dark:bg-zinc-950"
          style={{
            width: ".1563rem",
            left: `${position}%`,
            transform: "translateX(-50%)",
          }}
        />
      );
    });
  }, [chapterCues, seekableEnd, withoutChapter]);
 
  const spriteStyle = React.useMemo<React.CSSProperties>(() => {
    if (!thumbnail?.coords || !thumbnail?.src) {
      return {};
    }
 
    const coordX = thumbnail.coords[0];
    const coordY = thumbnail.coords[1];
 
    const spriteWidth = Number.parseFloat(thumbnail.coords[2] ?? "0");
    const spriteHeight = Number.parseFloat(thumbnail.coords[3] ?? "0");
 
    const scaleX = spriteWidth > 0 ? SPRITE_CONTAINER_WIDTH / spriteWidth : 1;
    const scaleY =
      spriteHeight > 0 ? SPRITE_CONTAINER_HEIGHT / spriteHeight : 1;
    const scale = Math.min(scaleX, scaleY);
 
    return {
      width: `${spriteWidth}px`,
      height: `${spriteHeight}px`,
      backgroundImage: `url(${thumbnail.src})`,
      backgroundPosition: `-${coordX}px -${coordY}px`,
      backgroundRepeat: "no-repeat",
      transform: `scale(${scale})`,
      transformOrigin: "top left",
    };
  }, [thumbnail?.coords, thumbnail?.src]);
 
  const SeekSlider = (
    <div data-slot="media-player-seek-container" className="relative w-full">
      <SliderPrimitive.Root
        aria-controls={context.mediaId}
        aria-valuetext={`${currentTime} of ${duration}`}
        data-slider=""
        data-slot="media-player-seek"
        disabled={isDisabled}
        {...seekProps}
        ref={seekRef}
        min={seekableStart}
        max={seekableEnd}
        step={0.01}
        className={cn(
          "relative flex w-full touch-none select-none items-center data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
          className,
        )}
        value={[displayValue]}
        onValueChange={onSeek}
        onValueCommit={onSeekCommit}
        onPointerEnter={onPointerEnter}
        onPointerLeave={onPointerLeave}
        onPointerMove={onPointerMove}
      >
        <SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-primary/40">
          <div
            data-slot="media-player-seek-buffered"
            className="absolute h-full bg-primary/70 will-change-[width]"
            style={{
              width: `${bufferedProgress * 100}%`,
            }}
          />
          <SliderPrimitive.Range className="absolute h-full bg-primary will-change-[width]" />
          {seekState.isHovering && seekableEnd > 0 && (
            <div
              data-slot="media-player-seek-hover-range"
              className="absolute h-full bg-primary/70 will-change-[width,opacity]"
              style={{
                width: `var(${SEEK_HOVER_PERCENT}, 0%)`,
                transition: "opacity 150ms ease-out",
              }}
            />
          )}
          {chapterSeparators}
        </SliderPrimitive.Track>
        <SliderPrimitive.Thumb className="relative z-10 block size-2.5 shrink-0 rounded-full bg-primary shadow-sm ring-ring/50 transition-[color,box-shadow] will-change-transform hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50" />
      </SliderPrimitive.Root>
      {!withoutTooltip &&
        !context.withoutTooltip &&
        seekState.isHovering &&
        seekableEnd > 0 && (
          <MediaPlayerPortal>
            <div
              ref={tooltipRef}
              className="pointer-events-none z-50 [backface-visibility:hidden] [contain:layout_style] [transition:opacity_150ms_ease-in-out]"
              style={{
                position: "fixed" as const,
                left: `var(${SEEK_TOOLTIP_X}, 0rem)`,
                top: `var(${SEEK_TOOLTIP_Y}, 0rem)`,
                transform: `translateX(-50%) translateY(calc(-100% - ${currentTooltipSideOffset}px))`,
                visibility: seekState.hasInitialPosition ? "visible" : "hidden",
                opacity: seekState.hasInitialPosition ? 1 : 0,
              }}
            >
              <div
                className={cn(
                  "flex flex-col items-center gap-1.5 rounded-md border bg-background text-foreground shadow-sm dark:bg-zinc-900",
                  thumbnail && "min-h-10",
                  !thumbnail && currentChapterCue && "px-3 py-1.5",
                )}
              >
                {thumbnail?.src && (
                  <div
                    data-slot="media-player-seek-thumbnail"
                    className="overflow-hidden rounded-md rounded-b-none"
                    style={{
                      width: `${SPRITE_CONTAINER_WIDTH}px`,
                      height: `${SPRITE_CONTAINER_HEIGHT}px`,
                    }}
                  >
                    {thumbnail.coords ? (
                      <div style={spriteStyle} />
                    ) : (
                      <img
                        src={thumbnail.src}
                        alt={`Preview at ${hoverTime}`}
                        className="size-full object-cover"
                      />
                    )}
                  </div>
                )}
                {currentChapterCue && (
                  <div
                    data-slot="media-player-seek-chapter-title"
                    className="line-clamp-2 max-w-48 text-balance text-center text-xs"
                  >
                    {currentChapterCue.text}
                  </div>
                )}
                <div
                  data-slot="media-player-seek-time"
                  className={cn(
                    "whitespace-nowrap text-center text-xs tabular-nums",
                    thumbnail && "pb-1.5",
                    !(thumbnail || currentChapterCue) && "px-2.5 py-1",
                  )}
                >
                  {tooltipTimeVariant === "progress"
                    ? `${hoverTime} / ${duration}`
                    : hoverTime}
                </div>
              </div>
            </div>
          </MediaPlayerPortal>
        )}
    </div>
  );
 
  if (withTime) {
    return (
      <div className="flex w-full items-center gap-2">
        <span className="text-sm tabular-nums">{currentTime}</span>
        {SeekSlider}
        <span className="text-sm tabular-nums">{remainingTime}</span>
      </div>
    );
  }
 
  return SeekSlider;
}
 
interface MediaPlayerVolumeProps
  extends React.ComponentProps<typeof SliderPrimitive.Root> {
  asChild?: boolean;
  expandable?: boolean;
}
 
function MediaPlayerVolume(props: MediaPlayerVolumeProps) {
  const {
    asChild,
    expandable = false,
    className,
    disabled,
    ...volumeProps
  } = props;
 
  const context = useMediaPlayerContext(VOLUME_NAME);
  const store = useStoreContext(VOLUME_NAME);
  const dispatch = useMediaDispatch();
  const mediaVolume = useMediaSelector((state) => state.mediaVolume ?? 1);
  const mediaMuted = useMediaSelector((state) => state.mediaMuted ?? false);
  const mediaVolumeLevel = useMediaSelector(
    (state) => state.mediaVolumeLevel ?? "high",
  );
 
  const sliderId = React.useId();
  const volumeTriggerId = React.useId();
 
  const isDisabled = disabled || context.disabled;
 
  const onMute = React.useCallback(() => {
    dispatch({
      type: mediaMuted
        ? MediaActionTypes.MEDIA_UNMUTE_REQUEST
        : MediaActionTypes.MEDIA_MUTE_REQUEST,
    });
  }, [dispatch, mediaMuted]);
 
  const onVolumeChange = React.useCallback(
    (value: number[]) => {
      const volume = value[0] ?? 0;
      store.setState("dragging", true);
      dispatch({
        type: MediaActionTypes.MEDIA_VOLUME_REQUEST,
        detail: volume,
      });
    },
    [dispatch, store.setState],
  );
 
  const onVolumeCommit = React.useCallback(
    (value: number[]) => {
      const volume = value[0] ?? 0;
      store.setState("dragging", false);
      dispatch({
        type: MediaActionTypes.MEDIA_VOLUME_REQUEST,
        detail: volume,
      });
    },
    [dispatch, store],
  );
 
  const effectiveVolume = mediaMuted ? 0 : mediaVolume;
 
  return (
    <div
      data-disabled={isDisabled ? "" : undefined}
      data-slot="media-player-volume-container"
      className={cn(
        "group flex items-center",
        expandable
          ? "gap-0 group-focus-within:gap-2 group-hover:gap-1.5"
          : "gap-1.5",
        className,
      )}
    >
      <MediaPlayerTooltip tooltip="Volume" shortcut="M">
        <Button
          id={volumeTriggerId}
          type="button"
          aria-controls={`${context.mediaId} ${sliderId}`}
          aria-label={mediaMuted ? "Unmute" : "Mute"}
          aria-pressed={mediaMuted}
          data-slot="media-player-volume-trigger"
          data-state={mediaMuted ? "on" : "off"}
          variant="ghost"
          size="icon"
          className="size-8"
          disabled={isDisabled}
          onClick={onMute}
        >
          {mediaVolumeLevel === "off" || mediaMuted ? (
            <VolumeXIcon />
          ) : mediaVolumeLevel === "high" ? (
            <Volume2Icon />
          ) : (
            <Volume1Icon />
          )}
        </Button>
      </MediaPlayerTooltip>
      <SliderPrimitive.Root
        id={sliderId}
        aria-controls={context.mediaId}
        aria-valuetext={`${Math.round(effectiveVolume * 100)}% volume`}
        data-slider=""
        data-slot="media-player-volume"
        {...volumeProps}
        min={0}
        max={1}
        step={0.1}
        className={cn(
          "relative flex touch-none select-none items-center",
          expandable
            ? "w-0 opacity-0 transition-[width,opacity] duration-200 ease-in-out group-focus-within:w-16 group-focus-within:opacity-100 group-hover:w-16 group-hover:opacity-100"
            : "w-16",
          className,
        )}
        disabled={isDisabled}
        value={[effectiveVolume]}
        onValueChange={onVolumeChange}
        onValueCommit={onVolumeCommit}
      >
        <SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-zinc-500">
          <SliderPrimitive.Range className="absolute h-full bg-primary will-change-[width]" />
        </SliderPrimitive.Track>
        <SliderPrimitive.Thumb className="block size-2.5 shrink-0 rounded-full bg-primary shadow-sm ring-ring/50 transition-[color,box-shadow] will-change-transform hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50" />
      </SliderPrimitive.Root>
    </div>
  );
}
 
interface MediaPlayerTimeProps extends React.ComponentProps<"div"> {
  variant?: "progress" | "remaining" | "duration";
  asChild?: boolean;
}
 
function MediaPlayerTime(props: MediaPlayerTimeProps) {
  const { variant = "progress", asChild, className, ...timeProps } = props;
 
  const context = useMediaPlayerContext("MediaPlayerTime");
  const mediaCurrentTime = useMediaSelector(
    (state) => state.mediaCurrentTime ?? 0,
  );
  const [, seekableEnd = 0] = useMediaSelector(
    (state) => state.mediaSeekable ?? [0, 0],
  );
 
  const times = React.useMemo(() => {
    if (variant === "remaining") {
      return {
        remaining: timeUtils.formatTime(
          seekableEnd - mediaCurrentTime,
          seekableEnd,
        ),
      };
    }
 
    if (variant === "duration") {
      return {
        duration: timeUtils.formatTime(seekableEnd, seekableEnd),
      };
    }
 
    return {
      current: timeUtils.formatTime(mediaCurrentTime, seekableEnd),
      duration: timeUtils.formatTime(seekableEnd, seekableEnd),
    };
  }, [variant, mediaCurrentTime, seekableEnd]);
 
  const TimePrimitive = asChild ? Slot : "div";
 
  if (variant === "remaining" || variant === "duration") {
    return (
      <TimePrimitive
        data-slot="media-player-time"
        data-variant={variant}
        dir={context.dir}
        {...timeProps}
        className={cn("text-foreground/80 text-sm tabular-nums", className)}
      >
        {times[variant]}
      </TimePrimitive>
    );
  }
 
  return (
    <TimePrimitive
      data-slot="media-player-time"
      data-variant={variant}
      dir={context.dir}
      {...timeProps}
      className={cn(
        "flex items-center gap-1 text-foreground/80 text-sm",
        className,
      )}
    >
      <span className="tabular-nums">{times.current}</span>
      <span role="separator" aria-hidden="true" tabIndex={-1}>
        /
      </span>
      <span className="tabular-nums">{times.duration}</span>
    </TimePrimitive>
  );
}
 
interface MediaPlayerPlaybackSpeedProps
  extends React.ComponentProps<typeof DropdownMenuTrigger>,
    React.ComponentProps<typeof Button>,
    Omit<React.ComponentProps<typeof DropdownMenu>, "dir">,
    Pick<React.ComponentProps<typeof DropdownMenuContent>, "sideOffset"> {
  speeds?: number[];
}
 
function MediaPlayerPlaybackSpeed(props: MediaPlayerPlaybackSpeedProps) {
  const {
    open,
    defaultOpen,
    onOpenChange: onOpenChangeProp,
    sideOffset = FLOATING_MENU_SIDE_OFFSET,
    speeds = SPEEDS,
    asChild,
    modal = false,
    className,
    disabled,
    ...playbackSpeedProps
  } = props;
 
  const context = useMediaPlayerContext(PLAYBACK_SPEED_NAME);
  const store = useStoreContext(PLAYBACK_SPEED_NAME);
  const dispatch = useMediaDispatch();
  const mediaPlaybackRate = useMediaSelector(
    (state) => state.mediaPlaybackRate ?? 1,
  );
 
  const isDisabled = disabled || context.disabled;
 
  const onPlaybackRateChange = React.useCallback(
    (rate: number) => {
      dispatch({
        type: MediaActionTypes.MEDIA_PLAYBACK_RATE_REQUEST,
        detail: rate,
      });
    },
    [dispatch],
  );
 
  const onOpenChange = React.useCallback(
    (open: boolean) => {
      store.setState("menuOpen", open);
      onOpenChangeProp?.(open);
    },
    [store.setState, onOpenChangeProp],
  );
 
  return (
    <DropdownMenu
      modal={modal}
      open={open}
      defaultOpen={defaultOpen}
      onOpenChange={onOpenChange}
    >
      <MediaPlayerTooltip tooltip="Playback speed" shortcut={["<", ">"]}>
        <DropdownMenuTrigger asChild>
          <Button
            type="button"
            aria-controls={context.mediaId}
            disabled={isDisabled}
            {...playbackSpeedProps}
            variant="ghost"
            size="icon"
            className={cn(
              "h-8 w-16 aria-[expanded=true]:bg-accent/50",
              className,
            )}
          >
            {mediaPlaybackRate}x
          </Button>
        </DropdownMenuTrigger>
      </MediaPlayerTooltip>
      <DropdownMenuContent
        container={context.portalContainer}
        sideOffset={sideOffset}
        align="center"
        className="min-w-[var(--radix-dropdown-menu-trigger-width)] data-[side=top]:mb-3.5"
      >
        {speeds.map((speed) => (
          <DropdownMenuItem
            key={speed}
            className="justify-between"
            onSelect={() => onPlaybackRateChange(speed)}
          >
            {speed}x{mediaPlaybackRate === speed && <CheckIcon />}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
 
interface MediaPlayerLoopProps extends React.ComponentProps<typeof Button> {}
 
function MediaPlayerLoop(props: MediaPlayerLoopProps) {
  const { children, className, disabled, ...loopProps } = props;
 
  const context = useMediaPlayerContext("MediaPlayerLoop");
  const isDisabled = disabled || context.disabled;
 
  const [isLooping, setIsLooping] = React.useState(() => {
    const mediaElement = context.mediaRef.current;
    return mediaElement?.loop ?? false;
  });
 
  React.useEffect(() => {
    const mediaElement = context.mediaRef.current;
    if (!mediaElement) return;
 
    setIsLooping(mediaElement.loop);
 
    const checkLoop = () => setIsLooping(mediaElement.loop);
    const observer = new MutationObserver(checkLoop);
    observer.observe(mediaElement, {
      attributes: true,
      attributeFilter: ["loop"],
    });
 
    return () => observer.disconnect();
  }, [context.mediaRef]);
 
  const onLoopToggle = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      props.onClick?.(event);
      if (event.defaultPrevented) return;
 
      const mediaElement = context.mediaRef.current;
      if (mediaElement) {
        const newLoopState = !mediaElement.loop;
        mediaElement.loop = newLoopState;
        setIsLooping(newLoopState);
      }
    },
    [context.mediaRef, props.onClick],
  );
 
  return (
    <MediaPlayerTooltip
      tooltip={isLooping ? "Disable loop" : "Enable loop"}
      shortcut="R"
    >
      <Button
        type="button"
        aria-controls={context.mediaId}
        aria-label={isLooping ? "Disable loop" : "Enable loop"}
        aria-pressed={isLooping}
        data-disabled={isDisabled ? "" : undefined}
        data-slot="media-player-loop"
        data-state={isLooping ? "on" : "off"}
        disabled={isDisabled}
        {...loopProps}
        variant="ghost"
        size="icon"
        className={cn("size-8", className)}
        onClick={onLoopToggle}
      >
        {children ??
          (isLooping ? (
            <RepeatIcon className="text-muted-foreground" />
          ) : (
            <RepeatIcon />
          ))}
      </Button>
    </MediaPlayerTooltip>
  );
}
 
interface MediaPlayerFullscreenProps
  extends React.ComponentProps<typeof Button> {}
 
function MediaPlayerFullscreen(props: MediaPlayerFullscreenProps) {
  const { children, className, disabled, ...fullscreenProps } = props;
 
  const context = useMediaPlayerContext("MediaPlayerFullscreen");
  const dispatch = useMediaDispatch();
  const isFullscreen = useMediaSelector(
    (state) => state.mediaIsFullscreen ?? false,
  );
 
  const isDisabled = disabled || context.disabled;
 
  const onFullscreen = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      props.onClick?.(event);
 
      if (event.defaultPrevented) return;
 
      dispatch({
        type: isFullscreen
          ? MediaActionTypes.MEDIA_EXIT_FULLSCREEN_REQUEST
          : MediaActionTypes.MEDIA_ENTER_FULLSCREEN_REQUEST,
      });
    },
    [dispatch, props.onClick, isFullscreen],
  );
 
  return (
    <MediaPlayerTooltip tooltip="Fullscreen" shortcut="F">
      <Button
        type="button"
        aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
        data-disabled={isDisabled ? "" : undefined}
        data-slot="media-player-fullscreen"
        data-state={isFullscreen ? "on" : "off"}
        disabled={isDisabled}
        {...fullscreenProps}
        variant="ghost"
        size="icon"
        className={cn("size-8", className)}
        onClick={onFullscreen}
      >
        {children ?? (isFullscreen ? <Minimize2Icon /> : <Maximize2Icon />)}
      </Button>
    </MediaPlayerTooltip>
  );
}
 
interface MediaPlayerPiPProps extends React.ComponentProps<typeof Button> {
  onPipError?: (error: unknown, state: "enter" | "exit") => void;
}
 
function MediaPlayerPiP(props: MediaPlayerPiPProps) {
  const { children, className, onPipError, disabled, ...pipButtonProps } =
    props;
 
  const context = useMediaPlayerContext("MediaPlayerPiP");
  const dispatch = useMediaDispatch();
  const isPictureInPicture = useMediaSelector(
    (state) => state.mediaIsPip ?? false,
  );
 
  const isDisabled = disabled || context.disabled;
 
  const onPictureInPicture = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      props.onClick?.(event);
 
      if (event.defaultPrevented) return;
 
      dispatch({
        type: isPictureInPicture
          ? MediaActionTypes.MEDIA_EXIT_PIP_REQUEST
          : MediaActionTypes.MEDIA_ENTER_PIP_REQUEST,
      });
 
      const mediaElement = context.mediaRef.current;
 
      if (mediaElement instanceof HTMLVideoElement) {
        if (isPictureInPicture) {
          document.exitPictureInPicture().catch((error) => {
            onPipError?.(error, "exit");
          });
        } else {
          mediaElement.requestPictureInPicture().catch((error) => {
            onPipError?.(error, "enter");
          });
        }
      }
    },
    [dispatch, props.onClick, isPictureInPicture, onPipError, context.mediaRef],
  );
 
  return (
    <MediaPlayerTooltip tooltip="Picture in picture" shortcut="P">
      <Button
        type="button"
        aria-controls={context.mediaId}
        aria-label={isPictureInPicture ? "Exit pip" : "Enter pip"}
        data-disabled={isDisabled ? "" : undefined}
        data-slot="media-player-pip"
        data-state={isPictureInPicture ? "on" : "off"}
        disabled={isDisabled}
        {...pipButtonProps}
        variant="ghost"
        size="icon"
        className={cn("size-8", className)}
        onClick={onPictureInPicture}
      >
        {isPictureInPicture ? (
          <PictureInPicture2Icon />
        ) : (
          <PictureInPictureIcon />
        )}
      </Button>
    </MediaPlayerTooltip>
  );
}
 
interface MediaPlayerCaptionsProps
  extends React.ComponentProps<typeof Button> {}
 
function MediaPlayerCaptions(props: MediaPlayerCaptionsProps) {
  const { children, className, disabled, ...captionsProps } = props;
 
  const context = useMediaPlayerContext("MediaPlayerCaptions");
  const dispatch = useMediaDispatch();
  const isSubtitlesActive = useMediaSelector(
    (state) => (state.mediaSubtitlesShowing ?? []).length > 0,
  );
 
  const isDisabled = disabled || context.disabled;
  const onCaptionsToggle = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      props.onClick?.(event);
 
      if (event.defaultPrevented) return;
 
      dispatch({
        type: MediaActionTypes.MEDIA_TOGGLE_SUBTITLES_REQUEST,
      });
    },
    [dispatch, props.onClick],
  );
 
  return (
    <MediaPlayerTooltip tooltip="Captions" shortcut="C">
      <Button
        type="button"
        aria-controls={context.mediaId}
        aria-label={isSubtitlesActive ? "Disable captions" : "Enable captions"}
        aria-pressed={isSubtitlesActive}
        data-disabled={isDisabled ? "" : undefined}
        data-slot="media-player-captions"
        data-state={isSubtitlesActive ? "on" : "off"}
        disabled={isDisabled}
        {...captionsProps}
        variant="ghost"
        size="icon"
        className={cn("size-8", className)}
        onClick={onCaptionsToggle}
      >
        {children ??
          (isSubtitlesActive ? <SubtitlesIcon /> : <CaptionsOffIcon />)}
      </Button>
    </MediaPlayerTooltip>
  );
}
 
interface MediaPlayerDownloadProps
  extends React.ComponentProps<typeof Button> {}
 
function MediaPlayerDownload(props: MediaPlayerDownloadProps) {
  const { children, className, disabled, ...downloadProps } = props;
 
  const context = useMediaPlayerContext("MediaPlayerDownload");
 
  const isDisabled = disabled || context.disabled;
 
  const onDownload = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      props.onClick?.(event);
 
      if (event.defaultPrevented) return;
 
      const mediaElement = context.mediaRef.current;
 
      if (!mediaElement || !mediaElement.currentSrc) return;
 
      const link = document.createElement("a");
      link.href = mediaElement.currentSrc;
      link.download = "";
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
    },
    [context.mediaRef, props.onClick],
  );
 
  return (
    <MediaPlayerTooltip tooltip="Download" shortcut="D">
      <Button
        type="button"
        aria-controls={context.mediaId}
        aria-label="Download"
        data-disabled={isDisabled ? "" : undefined}
        data-slot="media-player-download"
        disabled={isDisabled}
        {...downloadProps}
        variant="ghost"
        size="icon"
        className={cn("size-8", className)}
        onClick={onDownload}
      >
        {children ?? <DownloadIcon />}
      </Button>
    </MediaPlayerTooltip>
  );
}
 
interface MediaPlayerSettingsProps extends MediaPlayerPlaybackSpeedProps {}
 
function MediaPlayerSettings(props: MediaPlayerSettingsProps) {
  const {
    open,
    defaultOpen,
    onOpenChange: onOpenChangeProp,
    sideOffset = FLOATING_MENU_SIDE_OFFSET,
    speeds = SPEEDS,
    asChild,
    modal = false,
    className,
    disabled,
    ...settingsProps
  } = props;
 
  const context = useMediaPlayerContext(SETTINGS_NAME);
  const store = useStoreContext(SETTINGS_NAME);
  const dispatch = useMediaDispatch();
 
  const mediaPlaybackRate = useMediaSelector(
    (state) => state.mediaPlaybackRate ?? 1,
  );
  const mediaSubtitlesList = useMediaSelector(
    (state) => state.mediaSubtitlesList ?? [],
  );
  const mediaSubtitlesShowing = useMediaSelector(
    (state) => state.mediaSubtitlesShowing ?? [],
  );
  const mediaRenditionList = useMediaSelector(
    (state) => state.mediaRenditionList ?? [],
  );
  const selectedRenditionId = useMediaSelector(
    (state) => state.mediaRenditionSelected,
  );
 
  const isDisabled = disabled || context.disabled;
  const isSubtitlesActive = mediaSubtitlesShowing.length > 0;
 
  const onPlaybackRateChange = React.useCallback(
    (rate: number) => {
      dispatch({
        type: MediaActionTypes.MEDIA_PLAYBACK_RATE_REQUEST,
        detail: rate,
      });
    },
    [dispatch],
  );
 
  const onRenditionChange = React.useCallback(
    (renditionId: string) => {
      dispatch({
        type: MediaActionTypes.MEDIA_RENDITION_REQUEST,
        detail: renditionId === "auto" ? undefined : renditionId,
      });
    },
    [dispatch],
  );
 
  const onSubtitlesToggle = React.useCallback(() => {
    dispatch({
      type: MediaActionTypes.MEDIA_TOGGLE_SUBTITLES_REQUEST,
      detail: false,
    });
  }, [dispatch]);
 
  const onShowSubtitleTrack = React.useCallback(
    (subtitleTrack: (typeof mediaSubtitlesList)[number]) => {
      dispatch({
        type: MediaActionTypes.MEDIA_TOGGLE_SUBTITLES_REQUEST,
        detail: false,
      });
      dispatch({
        type: MediaActionTypes.MEDIA_SHOW_SUBTITLES_REQUEST,
        detail: subtitleTrack,
      });
    },
    [dispatch],
  );
 
  const selectedSubtitleLabel = React.useMemo(() => {
    if (!isSubtitlesActive) return "Off";
    if (mediaSubtitlesShowing.length > 0) {
      return mediaSubtitlesShowing[0]?.label ?? "On";
    }
    return "Off";
  }, [isSubtitlesActive, mediaSubtitlesShowing]);
 
  const selectedRenditionLabel = React.useMemo(() => {
    if (!selectedRenditionId) return "Auto";
 
    const currentRendition = mediaRenditionList?.find(
      (rendition) => rendition.id === selectedRenditionId,
    );
    if (!currentRendition) return "Auto";
 
    if (currentRendition.height) return `${currentRendition.height}p`;
    if (currentRendition.width) return `${currentRendition.width}p`;
    return currentRendition.id ?? "Auto";
  }, [selectedRenditionId, mediaRenditionList]);
 
  const onOpenChange = React.useCallback(
    (open: boolean) => {
      store.setState("menuOpen", open);
      onOpenChangeProp?.(open);
    },
    [store.setState, onOpenChangeProp],
  );
 
  return (
    <DropdownMenu
      modal={modal}
      open={open}
      defaultOpen={defaultOpen}
      onOpenChange={onOpenChange}
    >
      <MediaPlayerTooltip tooltip="Settings">
        <DropdownMenuTrigger asChild>
          <Button
            type="button"
            aria-controls={context.mediaId}
            aria-label="Settings"
            data-disabled={isDisabled ? "" : undefined}
            data-slot="media-player-settings"
            disabled={isDisabled}
            {...settingsProps}
            variant="ghost"
            size="icon"
            className={cn(
              "size-8 aria-[expanded=true]:bg-accent/50",
              className,
            )}
          >
            <SettingsIcon />
          </Button>
        </DropdownMenuTrigger>
      </MediaPlayerTooltip>
      <DropdownMenuContent
        align="end"
        side="top"
        sideOffset={sideOffset}
        container={context.portalContainer}
        className="w-56 data-[side=top]:mb-3.5"
      >
        <DropdownMenuLabel className="sr-only">Settings</DropdownMenuLabel>
        <DropdownMenuSub>
          <DropdownMenuSubTrigger>
            <span className="flex-1">Speed</span>
            <Badge variant="outline" className="rounded-sm">
              {mediaPlaybackRate}x
            </Badge>
          </DropdownMenuSubTrigger>
          <DropdownMenuSubContent>
            {speeds.map((speed) => (
              <DropdownMenuItem
                key={speed}
                className="justify-between"
                onSelect={() => onPlaybackRateChange(speed)}
              >
                {speed}x{mediaPlaybackRate === speed && <CheckIcon />}
              </DropdownMenuItem>
            ))}
          </DropdownMenuSubContent>
        </DropdownMenuSub>
        {context.isVideo && mediaRenditionList.length > 0 && (
          <DropdownMenuSub>
            <DropdownMenuSubTrigger>
              <span className="flex-1">Quality</span>
              <Badge variant="outline" className="rounded-sm">
                {selectedRenditionLabel}
              </Badge>
            </DropdownMenuSubTrigger>
            <DropdownMenuSubContent>
              <DropdownMenuItem
                className="justify-between"
                onSelect={() => onRenditionChange("auto")}
              >
                Auto
                {!selectedRenditionId && <CheckIcon />}
              </DropdownMenuItem>
              {mediaRenditionList
                .slice()
                .sort((a, b) => {
                  const aHeight = a.height ?? 0;
                  const bHeight = b.height ?? 0;
                  return bHeight - aHeight;
                })
                .map((rendition) => {
                  const label = rendition.height
                    ? `${rendition.height}p`
                    : rendition.width
                      ? `${rendition.width}p`
                      : (rendition.id ?? "Unknown");
 
                  const selected = rendition.id === selectedRenditionId;
 
                  return (
                    <DropdownMenuItem
                      key={rendition.id}
                      className="justify-between"
                      onSelect={() => onRenditionChange(rendition.id ?? "")}
                    >
                      {label}
                      {selected && <CheckIcon />}
                    </DropdownMenuItem>
                  );
                })}
            </DropdownMenuSubContent>
          </DropdownMenuSub>
        )}
        <DropdownMenuSub>
          <DropdownMenuSubTrigger>
            <span className="flex-1">Captions</span>
            <Badge variant="outline" className="rounded-sm">
              {selectedSubtitleLabel}
            </Badge>
          </DropdownMenuSubTrigger>
          <DropdownMenuSubContent>
            <DropdownMenuItem
              className="justify-between"
              onSelect={onSubtitlesToggle}
            >
              Off
              {!isSubtitlesActive && <CheckIcon />}
            </DropdownMenuItem>
            {mediaSubtitlesList.map((subtitleTrack) => {
              const isSelected = mediaSubtitlesShowing.some(
                (showingSubtitle) =>
                  showingSubtitle.label === subtitleTrack.label,
              );
              return (
                <DropdownMenuItem
                  key={`${subtitleTrack.kind}-${subtitleTrack.label}-${subtitleTrack.language}`}
                  className="justify-between"
                  onSelect={() => onShowSubtitleTrack(subtitleTrack)}
                >
                  {subtitleTrack.label}
                  {isSelected && <CheckIcon />}
                </DropdownMenuItem>
              );
            })}
            {mediaSubtitlesList.length === 0 && (
              <DropdownMenuItem disabled>
                No captions available
              </DropdownMenuItem>
            )}
          </DropdownMenuSubContent>
        </DropdownMenuSub>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
 
interface MediaPlayerPortalProps {
  container?: Element | DocumentFragment | null;
  children?: React.ReactNode;
}
 
function MediaPlayerPortal(props: MediaPlayerPortalProps) {
  const { container: containerProp, children } = props;
 
  const context = useMediaPlayerContext("MediaPlayerPortal");
  const container = containerProp ?? context.portalContainer;
 
  if (!container) return null;
 
  return ReactDOM.createPortal(children, container);
}
 
interface MediaPlayerTooltipProps
  extends React.ComponentProps<typeof Tooltip>,
    Pick<React.ComponentProps<typeof TooltipContent>, "sideOffset"> {
  tooltip?: string;
  shortcut?: string | string[];
}
 
function MediaPlayerTooltip(props: MediaPlayerTooltipProps) {
  const {
    tooltip,
    shortcut,
    delayDuration,
    sideOffset,
    children,
    ...tooltipProps
  } = props;
 
  const context = useMediaPlayerContext("MediaPlayerTooltip");
  const tooltipDelayDuration = delayDuration ?? context.tooltipDelayDuration;
  const tooltipSideOffset = sideOffset ?? context.tooltipSideOffset;
 
  if ((!tooltip && !shortcut) || context.withoutTooltip) return <>{children}</>;
 
  return (
    <Tooltip {...tooltipProps} delayDuration={tooltipDelayDuration}>
      <TooltipTrigger
        className="text-foreground focus-visible:ring-ring/50"
        asChild
      >
        {children}
      </TooltipTrigger>
      <TooltipContent
        container={context.portalContainer}
        sideOffset={tooltipSideOffset}
        className="flex items-center gap-2 border bg-accent px-2 py-1 font-medium text-foreground data-[side=top]:mb-3.5 dark:bg-zinc-900 [&>span]:hidden"
      >
        <p>{tooltip}</p>
        {Array.isArray(shortcut) ? (
          <div className="flex items-center gap-1">
            {shortcut.map((shortcutKey) => (
              <kbd
                key={shortcutKey}
                className="select-none rounded border bg-secondary px-1.5 py-0.5 font-mono text-[11.2px] text-foreground shadow-xs"
              >
                <abbr title={shortcutKey} className="no-underline">
                  {shortcutKey}
                </abbr>
              </kbd>
            ))}
          </div>
        ) : (
          shortcut && (
            <kbd
              key={shortcut}
              className="select-none rounded border bg-secondary px-1.5 py-px font-mono text-[11.2px] text-foreground shadow-xs"
            >
              <abbr title={shortcut} className="no-underline">
                {shortcut}
              </abbr>
            </kbd>
          )
        )}
      </TooltipContent>
    </Tooltip>
  );
}
 
export {
  MediaPlayerRoot as MediaPlayer,
  MediaPlayerVideo,
  MediaPlayerAudio,
  MediaPlayerControls,
  MediaPlayerControlsOverlay,
  MediaPlayerLoading,
  MediaPlayerError,
  MediaPlayerVolumeIndicator,
  MediaPlayerPlay,
  MediaPlayerSeekBackward,
  MediaPlayerSeekForward,
  MediaPlayerSeek,
  MediaPlayerVolume,
  MediaPlayerTime,
  MediaPlayerPlaybackSpeed,
  MediaPlayerLoop,
  MediaPlayerFullscreen,
  MediaPlayerPiP,
  MediaPlayerCaptions,
  MediaPlayerDownload,
  MediaPlayerSettings,
  MediaPlayerPortal,
  MediaPlayerTooltip,
  //
  MediaPlayerRoot as Root,
  MediaPlayerVideo as Video,
  MediaPlayerAudio as Audio,
  MediaPlayerControls as Controls,
  MediaPlayerControlsOverlay as ControlsOverlay,
  MediaPlayerLoading as Loading,
  MediaPlayerVolumeIndicator as VolumeIndicator,
  MediaPlayerError as Error,
  MediaPlayerPlay as Play,
  MediaPlayerSeekBackward as SeekBackward,
  MediaPlayerSeekForward as SeekForward,
  MediaPlayerSeek as Seek,
  MediaPlayerVolume as Volume,
  MediaPlayerTime as Time,
  MediaPlayerPlaybackSpeed as PlaybackSpeed,
  MediaPlayerLoop as Loop,
  MediaPlayerFullscreen as Fullscreen,
  MediaPlayerPiP as PiP,
  MediaPlayerCaptions as Captions,
  MediaPlayerDownload as Download,
  MediaPlayerSettings as Settings,
  MediaPlayerPortal as Portal,
  MediaPlayerTooltip as Tooltip,
  //
  useMediaSelector as useMediaPlayer,
  useStoreSelector as useMediaPlayerStore,
};

Layout

Import the parts, and compose them together.

import * as MediaPlayer from "@/components/ui/media-player";

<MediaPlayer.Root>
  <MediaPlayer.Video>
    <source src="..." type="video/mp4" />
  </MediaPlayer.Video>
  <MediaPlayer.Loading />
  <MediaPlayer.Error />
  <MediaPlayer.VolumeIndicator />
  <MediaPlayer.Controls>
    <MediaPlayer.ControlsOverlay />
    <MediaPlayer.Play />
    <MediaPlayer.SeekBackward />
    <MediaPlayer.SeekForward />
    <MediaPlayer.Volume />
    <MediaPlayer.Seek />
    <MediaPlayer.Time />
    <MediaPlayer.PlaybackSpeed />
    <MediaPlayer.Loop />
    <MediaPlayer.Captions />
    <MediaPlayer.PiP />
    <MediaPlayer.Fullscreen />
    <MediaPlayer.Download />
  </MediaPlayer.Controls>
</MediaPlayer.Root>

Examples

Audio Player

Use the Audio component instead of Video for audio playback.

Media playerAudio player with custom controls for playback, volume, seeking, and more. Use space bar to play/pause, Shift + arrow keys (←/→) to seek, and arrow keys (↑/↓) to adjust volume.
0:00
0:00
import {
  MediaPlayer,
  MediaPlayerAudio,
  MediaPlayerControls,
  MediaPlayerLoop,
  MediaPlayerPlay,
  MediaPlayerPlaybackSpeed,
  MediaPlayerSeek,
  MediaPlayerSeekBackward,
  MediaPlayerSeekForward,
  MediaPlayerVolume,
} from "@/components/ui/media-player";
 
export function MediaPlayerAudioDemo() {
  return (
    <MediaPlayer className="h-20">
      <MediaPlayerAudio className="sr-only">
        <source src="/assets/lofi.mp3" type="audio/mp3" />
      </MediaPlayerAudio>
      <MediaPlayerControls className="flex-col items-start gap-2.5">
        <MediaPlayerSeek withTime />
        <div className="flex w-full items-center justify-center gap-2">
          <MediaPlayerSeekBackward />
          <MediaPlayerPlay />
          <MediaPlayerSeekForward />
          <MediaPlayerVolume />
          <MediaPlayerPlaybackSpeed />
          <MediaPlayerLoop />
        </div>
      </MediaPlayerControls>
    </MediaPlayer>
  );
}

With Settings Menu

Media player with a settings menu with playback speed, caption option, and resolution selector.

Media playerAudio player with custom controls for playback, volume, seeking, and more. Use space bar to play/pause, Shift + arrow keys (←/→) to seek, and arrow keys (↑/↓) to adjust volume.
0:000:00
import {
  MediaPlayer,
  MediaPlayerCaptions,
  MediaPlayerControls,
  MediaPlayerControlsOverlay,
  MediaPlayerError,
  MediaPlayerFullscreen,
  MediaPlayerLoading,
  MediaPlayerPiP,
  MediaPlayerPlay,
  MediaPlayerSeek,
  MediaPlayerSeekBackward,
  MediaPlayerSeekForward,
  MediaPlayerSettings,
  MediaPlayerTime,
  MediaPlayerVideo,
  MediaPlayerVolume,
  MediaPlayerVolumeIndicator,
} from "@/components/ui/media-player";
 
export function MediaPlayerSettingsDemo() {
  return (
    <MediaPlayer autoHide>
      <MediaPlayerVideo
        src="https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008/low.mp4"
        crossOrigin=""
        muted
        playsInline
      >
        <track
          default
          kind="chapters"
          src="https://media-chrome.mux.dev/examples/vanilla/vtt/elephantsdream/chapters.vtt"
        />
        <track
          default
          kind="metadata"
          label="thumbnails"
          src="https://image.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008/storyboard.vtt"
        />
        <track
          label="English"
          kind="captions"
          srcLang="en"
          src="https://media-chrome.mux.dev/examples/vanilla/vtt/elephantsdream/captions.en.vtt"
          default
        />
        <track
          label="Japanese"
          kind="captions"
          srcLang="ja"
          src="https://media-chrome.mux.dev/examples/vanilla/vtt/elephantsdream/captions.ja.vtt"
        />
        <track
          label="Swedish"
          kind="captions"
          srcLang="sv"
          src="https://media-chrome.mux.dev/examples/vanilla/vtt/elephantsdream/captions.sv.vtt"
        />
      </MediaPlayerVideo>
      <MediaPlayerLoading />
      <MediaPlayerError />
      <MediaPlayerVolumeIndicator />
      <MediaPlayerControls className="flex-col items-start gap-2.5">
        <MediaPlayerControlsOverlay />
        <MediaPlayerSeek />
        <div className="flex w-full items-center gap-2">
          <div className="flex flex-1 items-center gap-2">
            <MediaPlayerPlay />
            <MediaPlayerSeekBackward />
            <MediaPlayerSeekForward />
            <MediaPlayerVolume expandable />
            <MediaPlayerTime />
          </div>
          <div className="flex items-center gap-2">
            <MediaPlayerCaptions />
            <MediaPlayerSettings />
            <MediaPlayerPiP />
            <MediaPlayerFullscreen />
          </div>
        </div>
      </MediaPlayerControls>
    </MediaPlayer>
  );
}

HLS Playback

Media player with HLS (HTTP Live Streaming) support for adaptive bitrate streaming.

This example demonstrates using the Mux Video React package as the video element to enable HLS playback.

Media playerAudio player with custom controls for playback, volume, seeking, and more. Use space bar to play/pause, Shift + arrow keys (←/→) to seek, and arrow keys (↑/↓) to adjust volume.
0:000:00
import {
  MediaPlayer,
  MediaPlayerCaptions,
  MediaPlayerControls,
  MediaPlayerControlsOverlay,
  MediaPlayerError,
  MediaPlayerFullscreen,
  MediaPlayerLoading,
  MediaPlayerPiP,
  MediaPlayerPlay,
  MediaPlayerSeek,
  MediaPlayerSeekBackward,
  MediaPlayerSeekForward,
  MediaPlayerSettings,
  MediaPlayerTime,
  MediaPlayerVideo,
  MediaPlayerVolume,
  MediaPlayerVolumeIndicator,
} from "@/components/ui/media-player";
import MuxVideo from "@mux/mux-video-react";
 
export function MediaPlayerHlsDemo() {
  return (
    <MediaPlayer autoHide>
      <MediaPlayerVideo asChild>
        <MuxVideo playbackId="A3VXy02VoUinw01pwyomEO3bHnG4P32xzV7u1j1FSzjNg" />
      </MediaPlayerVideo>
      <MediaPlayerLoading />
      <MediaPlayerError />
      <MediaPlayerVolumeIndicator />
      <MediaPlayerControls className="flex-col items-start gap-2.5">
        <MediaPlayerControlsOverlay />
        <MediaPlayerSeek />
        <div className="flex w-full items-center gap-2">
          <div className="flex flex-1 items-center gap-2">
            <MediaPlayerPlay />
            <MediaPlayerSeekBackward />
            <MediaPlayerSeekForward />
            <MediaPlayerVolume expandable />
            <MediaPlayerTime />
          </div>
          <div className="flex items-center gap-2">
            <MediaPlayerCaptions />
            <MediaPlayerSettings />
            <MediaPlayerPiP />
            <MediaPlayerFullscreen />
          </div>
        </div>
      </MediaPlayerControls>
    </MediaPlayer>
  );
}

With Error Handling

Media player with custom error handling and retry functionality.

Media playerAudio player with custom controls for playback, volume, seeking, and more. Use space bar to play/pause, Shift + arrow keys (←/→) to seek, and arrow keys (↑/↓) to adjust volume.
0:000:00
import {
  MediaPlayer,
  MediaPlayerControls,
  MediaPlayerControlsOverlay,
  MediaPlayerError,
  MediaPlayerFullscreen,
  MediaPlayerPiP,
  MediaPlayerPlay,
  MediaPlayerPlaybackSpeed,
  MediaPlayerSeek,
  MediaPlayerSeekBackward,
  MediaPlayerSeekForward,
  MediaPlayerTime,
  MediaPlayerVideo,
  MediaPlayerVolume,
} from "@/components/ui/media-player";
 
export function MediaPlayerErrorDemo() {
  return (
    <MediaPlayer>
      <MediaPlayerVideo
        src="/assets/nonexistent-video.mp4"
        playsInline
        crossOrigin=""
      />
      <MediaPlayerError />
      <MediaPlayerControls className="flex-col items-start gap-2.5">
        <MediaPlayerControlsOverlay />
        <MediaPlayerSeek />
        <div className="flex w-full items-center gap-2">
          <div className="flex flex-1 items-center gap-2">
            <MediaPlayerPlay />
            <MediaPlayerSeekBackward />
            <MediaPlayerSeekForward />
            <MediaPlayerVolume expandable />
            <MediaPlayerTime />
          </div>
          <div className="flex items-center gap-2">
            <MediaPlayerPlaybackSpeed />
            <MediaPlayerPiP />
            <MediaPlayerFullscreen />
          </div>
        </div>
      </MediaPlayerControls>
    </MediaPlayer>
  );
}

With Playlist

A media player example that includes a playlist, similar to music streaming applications.

Media playerAudio player with custom controls for playback, volume, seeking, and more. Use space bar to play/pause, Shift + arrow keys (←/→) to seek, and arrow keys (↑/↓) to adjust volume.
Medieval: Battle

Medieval: Battle

RandomMind

Playlist

1 / 2
0:000:00
"use client";
 
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
  MediaPlayer,
  MediaPlayerAudio,
  MediaPlayerControls,
  MediaPlayerPlay,
  MediaPlayerSeek,
  MediaPlayerTime,
  MediaPlayerTooltip,
  MediaPlayerVolume,
} from "@/components/ui/media-player";
import {
  ListMusicIcon,
  Loader2Icon,
  PauseCircleIcon,
  PlayCircleIcon,
  SkipBackIcon,
  SkipForwardIcon,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
 
interface Track {
  id: string;
  title: string;
  artist: string;
  src: string;
  cover: string;
}
 
const tracks: Track[] = [
  {
    id: "1",
    title: "Medieval: Battle",
    artist: "RandomMind",
    src: "https://opengameart.org/sites/default/files/battle.mp3",
    cover: "https://picsum.photos/seed/battle/200/200",
  },
  {
    id: "2",
    title: "City Lights",
    artist: "The Lemming Shepherds",
    src: "https://www.dropbox.com/s/mvvwaw1msplnteq/City%20Lights%20-%20The%20Lemming%20Shepherds.mp3?raw=1",
    cover: "https://picsum.photos/seed/citylights/200/200",
  },
];
 
export function MediaPlayerPlaylistDemo() {
  const [currentTrackIndex, setCurrentTrackIndex] = React.useState(0);
  const [isPlaying, setIsPlaying] = React.useState(false);
  const [isLoading, setIsLoading] = React.useState(false);
  const audioRef = React.useRef<HTMLAudioElement | null>(null);
  const shouldPlayAfterLoad = React.useRef(false);
 
  const onPlay = React.useCallback(() => {
    setIsPlaying(true);
  }, []);
 
  const onPause = React.useCallback(() => {
    setIsPlaying(false);
  }, []);
 
  const onEnded = React.useCallback(() => {
    onNextTrack();
  }, []);
 
  const onAudioPlay = React.useCallback(async () => {
    if (!audioRef.current) return;
 
    try {
      await audioRef.current.play();
      setIsPlaying(true);
    } catch (error) {
      toast.error(
        error instanceof Error ? error.message : "Failed to play track",
      );
      setIsPlaying(false);
    }
  }, []);
 
  const onLoadAndPlayTrack = React.useCallback(
    async (index: number, shouldPlay = true) => {
      const trackToPlay = tracks[index];
      if (!trackToPlay) {
        toast.error("Track not found");
        return;
      }
 
      if (!audioRef.current) return;
 
      if (!audioRef.current.paused) {
        audioRef.current.pause();
      }
 
      setCurrentTrackIndex(index);
      setIsLoading(true);
      shouldPlayAfterLoad.current = shouldPlay;
 
      audioRef.current.src = trackToPlay.src;
      audioRef.current.load();
    },
    [],
  );
 
  const onPlayTrack = React.useCallback(
    (index: number) => {
      onLoadAndPlayTrack(index, true);
    },
    [onLoadAndPlayTrack],
  );
 
  const onTogglePlayPauseTrack = (index: number) => {
    if (index === currentTrackIndex) {
      if (isPlaying) {
        audioRef.current?.pause();
      } else {
        onAudioPlay();
      }
    } else {
      onPlayTrack(index);
    }
  };
 
  const onNextTrack = React.useCallback(() => {
    const nextIndex = (currentTrackIndex + 1) % tracks.length;
    onPlayTrack(nextIndex);
  }, [currentTrackIndex, onPlayTrack]);
 
  const onPreviousTrack = React.useCallback(() => {
    const prevIndex = (currentTrackIndex - 1 + tracks.length) % tracks.length;
    onPlayTrack(prevIndex);
  }, [currentTrackIndex, onPlayTrack]);
 
  const currentTrack = React.useMemo(
    () => tracks[currentTrackIndex],
    [currentTrackIndex],
  );
 
  React.useEffect(() => {
    const audioElement = audioRef.current;
    if (!audioElement) return;
 
    const onCanPlay = () => {
      setIsLoading(false);
      if (shouldPlayAfterLoad.current) {
        onAudioPlay();
        shouldPlayAfterLoad.current = false;
      }
    };
 
    const onLoadStart = () => {
      setIsLoading(true);
    };
 
    const onError = () => {
      setIsLoading(false);
      setIsPlaying(false);
      toast.error("Failed to load track");
    };
 
    audioElement.addEventListener("canplay", onCanPlay);
    audioElement.addEventListener("loadstart", onLoadStart);
    audioElement.addEventListener("error", onError);
 
    return () => {
      audioElement.removeEventListener("canplay", onCanPlay);
      audioElement.removeEventListener("loadstart", onLoadStart);
      audioElement.removeEventListener("error", onError);
    };
  }, [onAudioPlay]);
 
  React.useEffect(() => {
    if (
      audioRef.current &&
      currentTrack &&
      audioRef.current.src !== currentTrack.src
    ) {
      onLoadAndPlayTrack(currentTrackIndex, false);
    }
  }, [currentTrack, currentTrackIndex, onLoadAndPlayTrack]);
 
  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      const isMediaFocused = event.currentTarget.contains(
        document.activeElement,
      );
 
      if (!isMediaFocused) return;
 
      switch (event.key.toLowerCase()) {
        case "n":
          event.preventDefault();
          onNextTrack();
          break;
 
        case "p":
          event.preventDefault();
          onPreviousTrack();
          break;
      }
    },
    [onNextTrack, onPreviousTrack],
  );
 
  if (!currentTrack) return null;
 
  return (
    <MediaPlayer
      onPlay={onPlay}
      onPause={onPause}
      onEnded={onEnded}
      onKeyDown={onKeyDown}
      className="w-full max-w-2xl overflow-hidden rounded-lg border bg-background shadow-lg"
    >
      <MediaPlayerAudio
        ref={audioRef}
        src={currentTrack.src}
        className="sr-only"
      />
      <div className="flex w-full flex-col items-center gap-4 md:items-start">
        <div className="relative w-full overflow-hidden rounded-md rounded-b-none border-b">
          <img
            src={currentTrack.cover}
            alt={currentTrack.title}
            className="h-40 w-full object-cover"
          />
          <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
          <div className="absolute right-0 bottom-0 left-0 p-4">
            <h2 className="font-semibold text-2xl text-white tracking-tight drop-shadow-lg">
              {currentTrack.title}
            </h2>
            <p className="text-sm text-white/90 drop-shadow-md">
              {currentTrack.artist}
            </p>
          </div>
        </div>
        <div className="w-full">
          <div className="flex items-center border-border border-b px-4 pb-4">
            <div className="flex flex-1 items-center gap-2">
              <h3 className="font-medium text-lg tracking-tight">Playlist</h3>
              <ListMusicIcon className="size-4" />
            </div>
            <span className="text-muted-foreground text-sm">{`${currentTrackIndex + 1} / ${tracks.length}`}</span>
          </div>
          <ScrollArea className="max-h-[200px]">
            {tracks.map((track, index) => (
              <Button
                key={track.id}
                variant="ghost"
                className={cn(
                  "h-auto w-full rounded-none px-4 py-3 text-left",
                  index === currentTrackIndex && "bg-accent",
                )}
                onClick={() => onTogglePlayPauseTrack(index)}
                disabled={isLoading}
              >
                <img
                  src={track.cover}
                  alt={track.title}
                  className="aspect-square size-9 rounded object-cover"
                />
                <div className="flex flex-1 flex-col">
                  <span
                    className={cn(
                      "font-medium leading-tight",
                      index === currentTrackIndex && "text-primary",
                    )}
                  >
                    {track.title}
                  </span>
                  <span className="text-muted-foreground text-sm">
                    {track.artist}
                  </span>
                </div>
                {index === currentTrackIndex && isLoading ? (
                  <Loader2Icon className="size-6 animate-spin text-primary" />
                ) : index === currentTrackIndex && isPlaying ? (
                  <PauseCircleIcon className="size-6 text-primary" />
                ) : index === currentTrackIndex && !isPlaying ? (
                  <PlayCircleIcon className="size-6 text-muted-foreground" />
                ) : (
                  <PlayCircleIcon className="size-6 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
                )}
              </Button>
            ))}
          </ScrollArea>
        </div>
        <MediaPlayerControls className="relative flex w-full flex-col gap-2.5">
          <MediaPlayerSeek />
          <div className="flex w-full items-center justify-center gap-2">
            <MediaPlayerTooltip tooltip="Previous track" shortcut="B">
              <Button
                aria-label="Previous track"
                variant="ghost"
                size="icon"
                className="size-8"
                onClick={onPreviousTrack}
                disabled={isLoading}
              >
                <SkipBackIcon />
              </Button>
            </MediaPlayerTooltip>
            <MediaPlayerPlay />
            <MediaPlayerTooltip tooltip="Next track" shortcut="N">
              <Button
                aria-label="Next track"
                variant="ghost"
                size="icon"
                className="size-8"
                onClick={onNextTrack}
                disabled={isLoading}
              >
                <SkipForwardIcon />
              </Button>
            </MediaPlayerTooltip>
            <MediaPlayerTime variant="progress" />
            <MediaPlayerVolume className="ml-auto" />
          </div>
        </MediaPlayerControls>
      </div>
    </MediaPlayer>
  );
}

API Reference

Root

The main container component for the media player.

PropTypeDefault
asChild?
boolean
false
withoutTooltip?
boolean
false
disabled?
boolean
false
autoHide?
boolean
false
tooltipDelayDuration?
number
600
tooltipSideOffset?
number
10
label?
string
"Media player"
dir?
"ltr" | "rtl"
-
onFullscreenChange?
((fullscreen: boolean) => void)
-
onPipError?
((error: unknown, state: "enter" | "exit") => void)
-
onMediaError?
((error: MediaError | null) => void)
-
onMuted?
((muted: boolean) => void)
-
onVolumeChange?
((volume: number) => void)
-
onTimeUpdate?
((time: number) => void)
-
onEnded?
(() => void)
-
onPause?
(() => void)
-
onPlay?
(() => void)
-
Data AttributeValue
[data-controls-visible]Present when the controls are visible.
[data-disabled]Present when the media player is disabled.
[data-state]"fullscreen" | "windowed"

Video

The video element, integrated with the player state.

PropTypeDefault
asChild?
boolean
false

Audio

The audio element, integrated with the player state.

PropTypeDefault
asChild?
boolean
false

Controls

A container for the media player controls.

PropTypeDefault
asChild?
boolean
false
Data AttributeValue
[data-disabled]Present when the controls are disabled.
[data-state]"fullscreen" | "windowed"
[data-visible]Present when the controls are visible.

ControlsOverlay

Displays as a subtle backdrop that improves readability for media controls.

PropTypeDefault
asChild?
boolean
false
Data AttributeValue
[data-state]"fullscreen" | "windowed"
[data-visible]Present when the controls are visible.

Loading

A loading indicator that appears when media is loading, similar to YouTube/Netflix style.

PropTypeDefault
asChild?
boolean
false
delay?
number
500

Error

An error component that displays when media playback encounters an error, with retry and reload options.

PropTypeDefault
asChild?
boolean
false
onReload?
(() => void)
-
onRetry?
(() => void)
-
description?
string
-
label?
string
-
error?
MediaError | null
-
Data AttributeValue
[data-state]"fullscreen" | "windowed"

VolumeIndicator

A volume indicator that appears when the volume is changed with keyboard interactions.

PropTypeDefault
asChild?
boolean
false

Play

A button to play or pause the media.

PropTypeDefault
asChild?
boolean
false
Data AttributeValue
[data-disabled]Present when the button is disabled.
[data-state]"on" | "off"

SeekBackward

A button to seek backward in the media.

PropTypeDefault
asChild?
boolean
false
seconds?
number
5
Data AttributeValue
[data-disabled]Present when the button is disabled.

SeekForward

A button to seek forward in the media.

PropTypeDefault
asChild?
boolean
false
seconds?
number
10
Data AttributeValue
[data-disabled]Present when the button is disabled.

Seek

A slider component to seek through the media playback.

PropTypeDefault
onValueCommit?
((value: number[]) => void)
-
onValueChange?
((value: number[]) => void)
-
value?
number[]
-
minStepsBetweenThumbs?
number
-
step?
number
-
orientation?
"horizontal" | "vertical"
-
disabled?
boolean
-
name?
string
-
asChild?
boolean
-
inverted?
boolean
-
max?
number
-
min?
number
-
form?
string
-
tooltipCollisionPadding?
number | Partial<Record<"top" | "right" | "bottom" | "left", number>>
10
tooltipCollisionBoundary?
Element | Element[]
-
tooltipSideOffset?
number
10
tooltipTimeVariant?
"progress" | "current"
"current"
tooltipThumbnailSrc?
string | ((time: number) => string)
-
withoutTooltip?
boolean
false
withoutChapter?
boolean
true
withTime?
boolean
false
Data AttributeValue
[data-disabled]Present when the slider is disabled.
[data-slider='']Present on the seek slider.
[data-slot='media-player-seek-container']The container for the seek slider.
[data-slot='media-player-seek']The root for the seek slider.
[data-slot='media-player-seek-buffered']The buffered progress indicator within the track.
[data-slot='media-player-seek-hover-range']The hover preview range indicator.
[data-slot='media-player-seek-chapter-separator']Chapter separators displayed on the track.
[data-slot='media-player-seek-thumbnail']The thumbnail preview container in the tooltip.
[data-slot='media-player-seek-chapter-title']The chapter title in the tooltip.
[data-slot='media-player-seek-time']The time display in the tooltip.

Styling

<MediaPlayer.Seek 
  className={
    cn(
      "[&_[data-slot='media-player-seek-buffered']]:bg-primary/60",
      "[&_[data-slot='media-player-seek-hover-range']]:bg-primary/70",
      "[&_[data-slot='media-player-seek-chapter-separator']]:w-1 [&_[data-slot='media-player-seek-chapter-separator']]:bg-muted",
      "[&_[data-slot='media-player-seek-thumbnail']]:border-2 [&_[data-slot='media-player-seek-thumbnail']]:border-ring"
    )
  }
/>

Volume

A slider component to control the media volume.

PropTypeDefault
onValueCommit?
((value: number[]) => void)
-
onValueChange?
((value: number[]) => void)
-
value?
number[]
-
minStepsBetweenThumbs?
number
-
step?
number
-
orientation?
"horizontal" | "vertical"
-
disabled?
boolean
-
name?
string
-
asChild?
boolean
-
inverted?
boolean
-
max?
number
-
min?
number
-
form?
string
-
expandable?
boolean
false
Data AttributeValue
[data-disabled]Present when the volume control is disabled.
[data-slider='']Present on the volume slider.
[data-slot='media-player-volume-container']The container for the volume slider.
[data-slot='media-player-volume-trigger']The trigger for the volume slider.
[data-slot='media-player-volume']The root for the volume slider.
[data-state]"on" | "off"

Time

Displays the current time, duration, or remaining time of the media.

PropTypeDefault
asChild?
boolean
false
variant?
"progress" | "remaining" | "duration"
"progress"

PlaybackSpeed

A select dropdown to control the media playback speed.

PropTypeDefault
size?
"default" | "sm" | "lg" | "icon" | null
-
variant?
"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null
-
asChild?
boolean
-
speeds?
number[]
[0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
sideOffset?
number
10
modal?
boolean
false
onOpenChange?
((open: boolean) => void)
-
open?
boolean
false
defaultOpen?
boolean
false
Data AttributeValue
[data-disabled]Present when the select is disabled.

Loop

A button to toggle loop mode.

PropTypeDefault
asChild?
boolean
false
Data AttributeValue
[data-disabled]Present when the button is disabled.
[data-state]"on" | "off"

Fullscreen

A button to toggle fullscreen mode.

PropTypeDefault
asChild?
boolean
false
Data AttributeValue
[data-disabled]Present when the button is disabled.
[data-state]"on" | "off"

PiP (Picture in picture)

A button to toggle picture in picture mode (video only).

PropTypeDefault
onPipError?
((error: unknown, state: "enter" | "exit") => void)
-
asChild?
boolean
false
Data AttributeValue
[data-disabled]Present when the button is disabled.
[data-state]"on" | "off"

Captions

A button to toggle captions or subtitles (video only).

PropTypeDefault
asChild?
boolean
false
Data AttributeValue
[data-disabled]Present when the button is disabled.
[data-state]"on" | "off"

Download

A button to download the media file.

PropTypeDefault
asChild?
boolean
false
Data AttributeValue
[data-disabled]Present when the button is disabled.

Settings

A dropdown menu with playback speed selector, caption selector, and resolution selector.

PropTypeDefault
speeds?
number[]
[0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
sideOffset?
number
10
modal?
boolean
false
onOpenChange?
((open: boolean) => void)
-
defaultOpen?
boolean
false
open?
boolean
false
size?
"default" | "sm" | "lg" | "icon" | null
-
variant?
"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null
-
asChild?
boolean
-

Features:

  • Playback speed control with customizable speeds
  • Video quality/resolution selection (when available)
  • Captions/subtitles control
  • Organized in a clean dropdown interface
<MediaPlayer.Settings 
  speeds={[0.5, 0.75, 1, 1.25, 1.5, 2]}
/>
Data AttributeValue
[data-disabled]Present when the button is disabled.

Accessibility

Keyboard Interactions

KeyDescription
SpaceKToggles play/pause.
ArrowRightSeeks forward 5 seconds (video only).
ArrowLeftSeeks backward 5 seconds (video only).
JSeeks backward 10 seconds (video only).
LSeeks forward 10 seconds (video only).
Shift + ArrowRightSeeks forward 5 seconds (audio only).
Shift + ArrowLeftSeeks backward 5 seconds (audio only).
0123456789Seeks to 0%-90% of duration.
HomeSeeks to beginning.
EndSeeks to end.
ArrowUpIncreases volume (video only).
ArrowDownDecreases volume (video only).
MToggles mute.
RToggles repeat mode.
FToggles fullscreen.
EscapeExits fullscreen when in fullscreen mode.
>Increases playback speed.
<Decreases playback speed.
CToggles captions (video only, if available).
PToggles Picture-in-Picture.
DDownloads the media file (if download button is present).

Credits

  • FASSounds - For the audio file used in basic examples.
  • Pika - For the video file used in basic examples.
  • Elephants Dream - Open movie by the Blender Foundation, used in settings demo under Creative Commons Attribution 2.5.
  • Mux - For HLS video streaming and VTT files used in HLS and settings demos.
  • Media Chrome - For VTT chapter and caption files used in examples.
  • RandomMind - For "Medieval: Battle" track used in playlist demo, available under CC0 from OpenGameArt.org.
  • The Lemming Shepherds - For "City Lights" track used in playlist demo.
  • Picsum - For placeholder cover images used in playlist demo.