Veltrix

media

Media Chapter Scrubber

A semantic audio or video scrubber with proportional chapters, buffered progress, boundary snapping, and contextual seek feedback.

AppleiOSAndroidAndroidExpoExpo Go
hapticsgesture handlerreanimated
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add expo-haptics react-native-gesture-handler react-native-reanimated

Anatomy

1 file

anatomy.tsx
<MediaChapterScrubber>  <MediaChapterScrubber.Chapter />  <MediaChapterScrubber.Chapters />  <MediaChapterScrubber.Preview />  <MediaChapterScrubber.Thumb />  <MediaChapterScrubber.Track /></MediaChapterScrubber>

Install tailwind variants

pnpm add tailwind-variants

Usage

MediaChapterScrubberExample.tsx
import { Text } from "react-native";import { MediaChapterScrubber } from "@animations/ui/components/media/media-chapter-scrubber/uniwind";const chapters = [  { id: "intro", title: "Introduction", startTime: 0, endTime: 32 },  { id: "interview", title: "Interview", startTime: 32, endTime: 108 },  { id: "closing", title: "Closing", startTime: 108, endTime: 146 },] as const;function formatTime(seconds: number) {  const minutes = Math.floor(seconds / 60);  const remainder = Math.floor(seconds % 60);  return `${minutes}:${remainder.toString().padStart(2, "0")}`;}export function MediaChapterScrubberExample() {  return (    <MediaChapterScrubber      bufferedTime={120}      chapters={chapters}      defaultCurrentTime={18}      onSeekComplete={(time, chapter) => {        console.log("Seek", time, chapter.id);      }}    >      <MediaChapterScrubber.Preview>        {({ activeChapter, currentTime, seeking }) => (          <>            <Text className="font-bold">{activeChapter.title}</Text>            <Text>{seeking ? "Release to seek" : formatTime(currentTime)}</Text>          </>        )}      </MediaChapterScrubber.Preview>          <MediaChapterScrubber.Track className="h-10">        <MediaChapterScrubber.Chapters className="h-2.5" />        <MediaChapterScrubber.Thumb className="size-4" />      </MediaChapterScrubber.Track>    </MediaChapterScrubber>  );}

Component files

5 files

media-chapter-scrubber/config.ts
export const MEDIA_CHAPTER_SCRUBBER_CONFIG = {  reportInterval: 0.1,  snapThreshold: 14,  spring: {    damping: 24,    mass: 0.75,    stiffness: 280,  },} as const;
media-chapter-scrubber/types.ts
import type { ReactNode } from "react";import type { SharedValue } from "react-native-reanimated";export type MediaChapter = {  endTime: number;  id: string;  startTime: number;  title: string;};export type MediaChapterScrubberRenderState = {  activeChapter: MediaChapter;  currentTime: number;  duration: number;  progress: SharedValue<number>;  seeking: boolean;};export type MediaChapterScrubberSlot =  | ReactNode  | ((      state: MediaChapterScrubberRenderState,    ) => ReactNode);export type MediaChapterScrubberBaseProps = {  bufferedTime?: number;  chapters: readonly [    MediaChapter,    ...MediaChapter[],  ];  children: ReactNode;  currentTime?: number;  defaultCurrentTime?: number;  disabled?: boolean;  haptics?: boolean;  onCurrentTimeChange?: (time: number) => void;  onSeekComplete?: (    time: number,    chapter: MediaChapter,  ) => void;  onSeekingChange?: (seeking: boolean) => void;  snapThreshold?: number;  snapToChapters?: boolean;};
media-chapter-scrubber/uniwind/index.tsx
import {  createContext,  type ReactNode,  useContext,} from "react";import {  View,  type AccessibilityActionEvent,  type ViewProps,} from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated, {  clamp,  useAnimatedStyle,} from "react-native-reanimated";import type {  MediaChapterScrubberBaseProps,  MediaChapterScrubberRenderState,  MediaChapterScrubberSlot,} from "../types";import { useMediaChapterScrubber } from "../use-media-chapter-scrubber";import { mediaChapterScrubberVariants } from "./variants";type MediaChapterScrubberController = ReturnType<  typeof useMediaChapterScrubber> & {  chapters: MediaChapterScrubberBaseProps["chapters"];  disabled: boolean;};const MediaChapterScrubberContext =  createContext<MediaChapterScrubberController | null>(    null,  );type MediaChapterScrubberPartProps = Omit<  ViewProps,  "children"> & {  children?: MediaChapterScrubberSlot;  className?: string;};type MediaChapterScrubberChapterProps = ViewProps & {  className?: string;  id: string;};type MediaChapterScrubberChaptersProps = Omit<  ViewProps,  "children"> & {  children?: (    chapter: MediaChapterScrubberBaseProps["chapters"][number],    index: number,  ) => ReactNode;  className?: string;};export type MediaChapterScrubberProps =  MediaChapterScrubberBaseProps & {    className?: string;  };function useMediaChapterScrubberContext() {  const context = useContext(    MediaChapterScrubberContext,  );  if (!context) {    throw new Error(      "MediaChapterScrubber compounds must be used inside MediaChapterScrubber.",    );  }  return context;}function renderSlot(  children: MediaChapterScrubberSlot | undefined,  state: MediaChapterScrubberRenderState,) {  return typeof children === "function"    ? children(state)    : children;}/** Measured gesture surface that owns adjustable accessibility actions. */function MediaChapterScrubberTrack({  accessibilityLabel = "Media chapters",  children,  className,  ...viewProps}: MediaChapterScrubberPartProps) {  const controller = useMediaChapterScrubberContext();  const { track } = mediaChapterScrubberVariants();  function handleAccessibilityAction(    event: AccessibilityActionEvent,  ) {    if (controller.disabled) {      return;    }    if (event.nativeEvent.actionName === "increment") {      controller.seekByChapter(1);    }    if (event.nativeEvent.actionName === "decrement") {      controller.seekByChapter(-1);    }  }  return (    <GestureDetector gesture={controller.gesture}>      <View        {...viewProps}        accessibilityActions={[          { name: "increment" },          { name: "decrement" },        ]}        accessibilityLabel={accessibilityLabel}        accessibilityRole="adjustable"        accessibilityState={{          disabled: controller.disabled,        }}        accessible        className={track({ className })}        onAccessibilityAction={handleAccessibilityAction}        onLayout={controller.onTrackLayout}      >        {renderSlot(children, {          activeChapter: controller.activeChapter,          currentTime: controller.resolvedTime,          duration: controller.duration,          progress: controller.progress,          seeking: controller.seeking,        })}      </View>    </GestureDetector>  );}/** One proportional buffered and played segment. Most consumers should use `Chapters`, which creates these segments from the root `chapters` model. */function MediaChapterScrubberChapter({  className,  id,  style,  ...viewProps}: MediaChapterScrubberChapterProps) {  const controller = useMediaChapterScrubberContext();  const chapter = controller.chapters.find(    (candidate) => candidate.id === id,  );  if (!chapter) {    throw new Error(`Unknown media chapter: ${id}`);  }  const progress = controller.progress;  const bufferedProgress = controller.bufferedProgress;  const duration = controller.duration;  const chapterDuration =    chapter.endTime - chapter.startTime;  const { chapter: chapterSlot, chapterBuffer, chapterProgress } =    mediaChapterScrubberVariants();  const progressStyle = useAnimatedStyle(() => ({    width: `${clamp(      (progress.get() * duration - chapter.startTime) /        chapterDuration,      0,      1,    ) * 100}%`,  }));  const bufferStyle = useAnimatedStyle(() => ({    width: `${clamp(      (bufferedProgress.get() * duration -        chapter.startTime) /        chapterDuration,      0,      1,    ) * 100}%`,  }));  return (    <View      {...viewProps}      className={chapterSlot({ className })}      style={[{ flexGrow: chapterDuration }, style]}    >      <Animated.View        className={chapterBuffer()}        style={bufferStyle}      />      <Animated.View        className={chapterProgress()}        style={progressStyle}      />    </View>  );}/** Renders every segment from the root `chapters` array, keeping that array as the single source of truth. */function MediaChapterScrubberChapters({  children,  className,  ...viewProps}: MediaChapterScrubberChaptersProps) {  const controller = useMediaChapterScrubberContext();  return (    <>      {controller.chapters.map((chapter, index) =>        children ? (          children(chapter, index)        ) : (          <MediaChapterScrubberChapter            {...viewProps}            className={className}            id={chapter.id}            key={chapter.id}          />        ),      )}    </>  );}/** Measured draggable position indicator. */function MediaChapterScrubberThumb({  className,  ...viewProps}: ViewProps & { className?: string }) {  const controller = useMediaChapterScrubberContext();  const { thumb } = mediaChapterScrubberVariants();  return (    <Animated.View      {...viewProps}      className={thumb({ className })}      onLayout={controller.onThumbLayout}      style={controller.thumbAnimatedStyle}    />  );}/** Consumer-owned chapter title, time label, thumbnail, or other seek feedback. */function MediaChapterScrubberPreview({  children,  className,  ...viewProps}: MediaChapterScrubberPartProps) {  const controller = useMediaChapterScrubberContext();  const { preview } = mediaChapterScrubberVariants();  return (    <View      {...viewProps}      className={preview({ className })}    >      {renderSlot(children, {        activeChapter: controller.activeChapter,        currentTime: controller.resolvedTime,        duration: controller.duration,        progress: controller.progress,        seeking: controller.seeking,      })}    </View>  );}function MediaChapterScrubberRoot({  bufferedTime,  chapters,  children,  className,  currentTime,  defaultCurrentTime,  disabled = false,  haptics,  onCurrentTimeChange,  onSeekComplete,  onSeekingChange,  snapThreshold,  snapToChapters,}: MediaChapterScrubberProps) {  const controller = useMediaChapterScrubber({    bufferedTime,    chapters,    currentTime,    defaultCurrentTime,    disabled,    haptics,    onCurrentTimeChange,    onSeekComplete,    onSeekingChange,    snapThreshold,    snapToChapters,  });  const { root } = mediaChapterScrubberVariants({    disabled,  });  return (    <MediaChapterScrubberContext.Provider      value={{ ...controller, chapters, disabled }}    >      <View className={root({ className })}>        {children}      </View>    </MediaChapterScrubberContext.Provider>  );}export const MediaChapterScrubber = Object.assign(  MediaChapterScrubberRoot,  {    Chapter: MediaChapterScrubberChapter,    Chapters: MediaChapterScrubberChapters,    Preview: MediaChapterScrubberPreview,    Thumb: MediaChapterScrubberThumb,    Track: MediaChapterScrubberTrack,  },);
media-chapter-scrubber/uniwind/variants.ts
import { tv } from "tailwind-variants";const mediaChapterScrubberSlots = {  chapter:    "h-2 flex-[1_1_0%] overflow-hidden rounded-md bg-[#d9d8d1]",  chapterBuffer:    "absolute inset-y-0 left-0 bg-[#b8b6ad]",  chapterProgress:    "absolute inset-y-0 left-0 bg-[#6f5cff]",  preview:    "flex-row items-center justify-between",  root: "w-full gap-3.5",  thumb:    "absolute left-0 size-4 rounded-full border-2 border-white bg-[#171713]",  track:    "h-7 w-full flex-row items-center gap-1",} as const;export type MediaChapterScrubberSlotName =  keyof typeof mediaChapterScrubberSlots;export const mediaChapterScrubberVariants = tv({  slots: mediaChapterScrubberSlots,  variants: {    disabled: {      true: {        root: "opacity-50",      },    },  },});
media-chapter-scrubber/use-media-chapter-scrubber.ts
import {  useCallback,  useMemo,  useState,} from "react";import * as Haptics from "expo-haptics";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import {  clamp,  runOnJS,  useAnimatedReaction,  useAnimatedStyle,  useReducedMotion,  useSharedValue,  withSpring,} from "react-native-reanimated";import { MEDIA_CHAPTER_SCRUBBER_CONFIG } from "./config";import type {  MediaChapter,  MediaChapterScrubberBaseProps,} from "./types";type UseMediaChapterScrubberOptions = Omit<  MediaChapterScrubberBaseProps,  "children">;export function useMediaChapterScrubber({  bufferedTime = 0,  chapters,  currentTime,  defaultCurrentTime = 0,  disabled = false,  haptics = true,  onCurrentTimeChange,  onSeekComplete,  onSeekingChange,  snapThreshold = MEDIA_CHAPTER_SCRUBBER_CONFIG.snapThreshold,  snapToChapters = true,}: UseMediaChapterScrubberOptions) {  const duration = chapters.at(-1)?.endTime ?? 0;  const initialTime = clamp(    currentTime ?? defaultCurrentTime,    0,    duration,  );  const [internalTime, setInternalTime] =    useState(initialTime);  const [seeking, setSeeking] = useState(false);  const resolvedTime = currentTime ?? internalTime;  const progress = useSharedValue(    duration === 0 ? 0 : resolvedTime / duration,  );  const bufferedProgress = useSharedValue(    duration === 0      ? 0      : clamp(bufferedTime / duration, 0, 1),  );  const trackWidth = useSharedValue(0);  const thumbWidth = useSharedValue(0);  const ownsProgress = useSharedValue(false);  const lastReportedTime = useSharedValue(resolvedTime);  const activeChapterIndex = useSharedValue(    Math.max(      chapters.findIndex(        (chapter) =>          initialTime >= chapter.startTime &&          initialTime < chapter.endTime,      ),      0,    ),  );  const reducedMotion = useReducedMotion();  const boundaries = useMemo(    () => chapters.map((chapter) => chapter.startTime),    [chapters],  );  const activeChapter =    chapters.find(      (chapter) =>        resolvedTime >= chapter.startTime &&        resolvedTime < chapter.endTime,    ) ??    chapters.at(-1) ??    chapters[0];  const reportTime = useCallback(    (time: number) => {      if (currentTime === undefined) {        setInternalTime(time);      }      onCurrentTimeChange?.(time);    },    [currentTime, onCurrentTimeChange],  );  const beginSeeking = useCallback(() => {    setSeeking(true);    onSeekingChange?.(true);  }, [onSeekingChange]);  const crossChapter = useCallback(    (index: number) => {      if (haptics) {        void Haptics.selectionAsync();      }    },    [haptics],  );  const finishSeeking = useCallback(    (time: number, index: number) => {      reportTime(time);      setSeeking(false);      onSeekingChange?.(false);      onSeekComplete?.(time, chapters[index]);    },    [      chapters,      onSeekComplete,      onSeekingChange,      reportTime,    ],  );  const completeTapSeek = useCallback(    (time: number, index: number) => {      reportTime(time);      onSeekComplete?.(time, chapters[index]);    },    [chapters, onSeekComplete, reportTime],  );  const seekByChapter = useCallback(    (direction: -1 | 1) => {      if (disabled) {        return;      }      const currentIndex = chapters.findIndex(        (chapter) => chapter.id === activeChapter.id,      );      const nextIndex = clamp(        currentIndex + direction,        0,        chapters.length - 1,      );      const nextTime = chapters[nextIndex].startTime;      const nextProgress =        duration === 0 ? 0 : nextTime / duration;      ownsProgress.set(true);      if (reducedMotion) {        progress.set(nextProgress);        ownsProgress.set(false);      } else {        progress.set(          withSpring(            nextProgress,            MEDIA_CHAPTER_SCRUBBER_CONFIG.spring,            (finished) => {              if (finished) {                ownsProgress.set(false);              }            },          ),        );      }      reportTime(nextTime);      onSeekComplete?.(nextTime, chapters[nextIndex]);      if (haptics && nextIndex !== currentIndex) {        void Haptics.selectionAsync();      }    },    [      activeChapter.id,      chapters,      disabled,      duration,      haptics,      onSeekComplete,      ownsProgress,      progress,      reducedMotion,      reportTime,    ],  );  useAnimatedReaction(    () =>      duration === 0        ? 0        : clamp(resolvedTime / duration, 0, 1),    (nextProgress) => {      if (ownsProgress.get()) {        return;      }      progress.set(nextProgress);    },  );  useAnimatedReaction(    () =>      duration === 0        ? 0        : clamp(bufferedTime / duration, 0, 1),    (nextProgress) => {      bufferedProgress.set(nextProgress);    },  );  const panGesture = useMemo(    () =>      Gesture.Pan()        .enabled(!disabled && duration > 0)        .onStart((event) => {          const width = trackWidth.get();          const availableTravel =            width - thumbWidth.get();          if (availableTravel <= 0) {            return;          }          runOnJS(beginSeeking)();          ownsProgress.set(true);          progress.set(            clamp(              (event.x - thumbWidth.get() / 2) /                availableTravel,              0,              1,            ),          );        })        .onUpdate((event) => {          const width = trackWidth.get();          const availableTravel =            width - thumbWidth.get();          if (availableTravel <= 0) {            return;          }          const nextProgress = clamp(            (event.x - thumbWidth.get() / 2) /              availableTravel,            0,            1,          );          const nextTime = nextProgress * duration;          let nextChapterIndex = chapters.length - 1;          for (            let index = 0;            index < chapters.length;            index += 1          ) {            if (nextTime < chapters[index].endTime) {              nextChapterIndex = index;              break;            }          }          progress.set(nextProgress);          if (            nextChapterIndex !== activeChapterIndex.get()          ) {            activeChapterIndex.set(nextChapterIndex);            runOnJS(crossChapter)(nextChapterIndex);          }          if (            Math.abs(              nextTime - lastReportedTime.get(),            ) >=            MEDIA_CHAPTER_SCRUBBER_CONFIG.reportInterval          ) {            lastReportedTime.set(nextTime);            runOnJS(reportTime)(nextTime);          }        })        .onEnd(() => {          const width = trackWidth.get();          const availableTravel =            width - thumbWidth.get();          let targetTime = progress.get() * duration;          if (            snapToChapters &&            availableTravel > 0          ) {            const timeThreshold =              (snapThreshold / availableTravel) *              duration;            let closestBoundary = targetTime;            let closestDistance = timeThreshold;            for (              let index = 0;              index < boundaries.length;              index += 1            ) {              const distance = Math.abs(                boundaries[index] - targetTime,              );              if (distance <= closestDistance) {                closestBoundary = boundaries[index];                closestDistance = distance;              }            }            targetTime = closestBoundary;          }          const targetProgress =            duration === 0 ? 0 : targetTime / duration;          let targetChapterIndex = chapters.length - 1;          for (            let index = 0;            index < chapters.length;            index += 1          ) {            if (targetTime < chapters[index].endTime) {              targetChapterIndex = index;              break;            }          }          if (            targetChapterIndex !==            activeChapterIndex.get()          ) {            activeChapterIndex.set(targetChapterIndex);            runOnJS(crossChapter)(targetChapterIndex);          }          if (reducedMotion) {            progress.set(targetProgress);            ownsProgress.set(false);          } else {            progress.set(              withSpring(                targetProgress,                MEDIA_CHAPTER_SCRUBBER_CONFIG.spring,                (finished) => {                  if (finished) {                    ownsProgress.set(false);                  }                },              ),            );          }          runOnJS(finishSeeking)(            targetTime,            targetChapterIndex,          );        }),    [      activeChapterIndex,      beginSeeking,      boundaries,      chapters,      crossChapter,      disabled,      duration,      finishSeeking,      lastReportedTime,      ownsProgress,      progress,      reducedMotion,      reportTime,      snapThreshold,      snapToChapters,      thumbWidth,      trackWidth,    ],  );  const tapGesture = useMemo(    () =>      Gesture.Tap()        .enabled(!disabled && duration > 0)        .onEnd((event, finished) => {          if (!finished) {            return;          }          const width = trackWidth.get();          const availableTravel =            width - thumbWidth.get();          if (availableTravel <= 0) {            return;          }          const targetProgress = clamp(            (event.x - thumbWidth.get() / 2) /              availableTravel,            0,            1,          );          const targetTime = targetProgress * duration;          let targetChapterIndex = chapters.length - 1;          for (            let index = 0;            index < chapters.length;            index += 1          ) {            if (targetTime < chapters[index].endTime) {              targetChapterIndex = index;              break;            }          }          if (            targetChapterIndex !==            activeChapterIndex.get()          ) {            activeChapterIndex.set(targetChapterIndex);            runOnJS(crossChapter)(targetChapterIndex);          }          ownsProgress.set(true);          if (reducedMotion) {            progress.set(targetProgress);            ownsProgress.set(false);          } else {            progress.set(              withSpring(                targetProgress,                MEDIA_CHAPTER_SCRUBBER_CONFIG.spring,                (springFinished) => {                  if (springFinished) {                    ownsProgress.set(false);                  }                },              ),            );          }          runOnJS(completeTapSeek)(            targetTime,            targetChapterIndex,          );        }),    [      activeChapterIndex,      chapters,      completeTapSeek,      crossChapter,      disabled,      duration,      ownsProgress,      progress,      reducedMotion,      thumbWidth,      trackWidth,    ],  );  const gesture = useMemo(    () => Gesture.Race(panGesture, tapGesture),    [panGesture, tapGesture],  );  const onTrackLayout = useCallback(    (event: LayoutChangeEvent) => {      trackWidth.set(event.nativeEvent.layout.width);    },    [trackWidth],  );  const onThumbLayout = useCallback(    (event: LayoutChangeEvent) => {      thumbWidth.set(event.nativeEvent.layout.width);    },    [thumbWidth],  );  const thumbAnimatedStyle = useAnimatedStyle(() => ({    transform: [      {        translateX:          progress.get() *          Math.max(            trackWidth.get() - thumbWidth.get(),            0,          ),      },    ],  }));  return {    activeChapter,    bufferedProgress,    duration,    gesture,    onThumbLayout,    onTrackLayout,    progress,    resolvedTime,    seekByChapter,    seeking,    thumbAnimatedStyle,  };}

API reference

Generated directly from the exported component props.

MediaChapterScrubber

MediaChapterScrubber.Chapter

One proportional buffered and played segment. Most consumers should use `Chapters`, which creates these segments from the root `chapters` model.

MediaChapterScrubber.Chapter extends all props from ViewProps, with the additional component-specific props shown below.

MediaChapterScrubber.Chapters

Renders every segment from the root `chapters` array, keeping that array as the single source of truth.

MediaChapterScrubber.Preview

Consumer-owned chapter title, time label, thumbnail, or other seek feedback.

MediaChapterScrubber.Thumb

Measured draggable position indicator.

MediaChapterScrubber.Thumb extends all props from ViewProps, with the additional component-specific props shown below.

MediaChapterScrubber.Track

Measured gesture surface that owns adjustable accessibility actions.