Reel Player

一个全屏的 Instagram Reels / TikTok 风格视频播放器组件,基于 @reelkit/react-reel-player.

查看在线演示 →

特性

竖向滑动
触摸、拖拽、键盘、滚轮
视频自动播放
可见时播放
声音开关
iOS 上声音连续
多媒体
嵌套的横向轮播
位置记忆
从上次的位置继续
抽帧
封面图到视频的交叉淡入
虚拟化
DOM 里只有 3 张幻灯片
宽高比
桌面端 9:16,移动端全屏
桌面端导航
箭头按钮
泛型类型
自定义内容数据模型
可定制
所有部分都可通过 render props 替换
幻灯片浮层
作者、点赞、描述
URL 状态
可分享、可收藏的链接

安装

bash
npm install @reelkit/react-reel-player @reelkit/react lucide-react

别忘了引入样式:

typescript
import '@reelkit/react-reel-player/styles.css';
图标

默认控件使用 lucide-react 作为图标。如果你想换一套图标库,可以用 renderControls 和 renderNavigation 提供自己的实现。

快速上手

ReelPlayerOverlay 组件渲染一个全屏播放器浮层。传入一组 ContentItem 对象,并用 isOpen.

tsx
import { useState } from 'react';
import { ReelPlayerOverlay, type ContentItem } from '@reelkit/react-reel-player';
import '@reelkit/react-reel-player/styles.css';

const content: ContentItem[] = [
  {
    id: '1',
    media: [{
      id: 'v1',
      type: 'video',
      src: 'https://example.com/video.mp4',
      poster: 'https://example.com/poster.jpg',
      aspectRatio: 9 / 16,
    }],
    author: { name: 'John Doe', avatar: 'https://example.com/avatar.jpg' },
    likes: 1234,
    description: 'Amazing video!',
  },
];

function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Open Player</button>
      <ReelPlayerOverlay
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        content={content}
      />
    </>
  );
}
ReelPlayerPage.tsx
import { useState } from 'react';
import { ReelPlayerOverlay, type ContentItem } from '@reelkit/react-reel-player';
import '@reelkit/react-reel-player/styles.css';

const content: ContentItem[] = [
  {
    id: '1',
    media: [{
      id: 'v1',
      type: 'video',
      src: '/cdn/samples/videos/video-01.mp4',
      poster: '/cdn/samples/videos/video-poster-01.jpg',
      aspectRatio: 16 / 9,
    }],
    author: { name: 'Alex Johnson', avatar: '/cdn/samples/avatars/avatar-01.jpg' },
    likes: 1234,
    description: 'Amazing sunset vibes',
  },
  {
    id: '2',
    media: [{
      id: 'img1',
      type: 'image',
      src: '/cdn/samples/images/image-01.jpg',
      aspectRatio: 2 / 3,
    }],
    author: { name: 'Sarah Miller', avatar: '/cdn/samples/avatars/avatar-02.jpg' },
    likes: 5678,
    description: 'Nature at its finest',
  },
  {
    id: '3',
    media: [{
      id: 'v2',
      type: 'video',
      src: '/cdn/samples/videos/video-04.mp4',
      poster: '/cdn/samples/videos/video-poster-04.jpg',
      aspectRatio: 16 / 9,
    }],
    author: { name: 'James Wilson', avatar: '/cdn/samples/avatars/avatar-03.jpg' },
    likes: 3456,
    description: 'City life adventures',
  },
  {
    id: '4',
    media: [
      { id: 'img2', type: 'image', src: '/cdn/samples/images/image-02.jpg', aspectRatio: 2 / 3 },
      { id: 'img3', type: 'image', src: '/cdn/samples/images/image-03.jpg', aspectRatio: 3 / 4 },
    ],
    author: { name: 'Emma Davis', avatar: '/cdn/samples/avatars/avatar-04.jpg' },
    likes: 8901,
    description: 'Travel moments',
  },
  {
    id: '5',
    media: [{
      id: 'img4',
      type: 'image',
      src: '/cdn/samples/images/image-04.jpg',
      aspectRatio: 2 / 3,
    }],
    author: { name: 'Michael Brown', avatar: '/cdn/samples/avatars/avatar-05.jpg' },
    likes: 2345,
    description: 'Golden hour magic',
  },
  {
    id: '6',
    media: [{
      id: 'v3',
      type: 'video',
      src: '/cdn/samples/videos/video-05.mp4',
      poster: '/cdn/samples/videos/video-poster-05.jpg',
      aspectRatio: 16 / 9,
    }],
    author: { name: 'Alex Johnson', avatar: '/cdn/samples/avatars/avatar-01.jpg' },
    likes: 7890,
    description: 'Living the moment',
  },
];

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [initialIndex, setInitialIndex] = useState(0);

  return (
    <div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
        {content.map((item, i) => (
          <button
            key={item.id}
            onClick={() => { setInitialIndex(i); setIsOpen(true); }}
            style={{
              position: 'relative', aspectRatio: '9 / 16', borderRadius: 8,
              overflow: 'hidden', border: 'none', padding: 0, cursor: 'pointer',
              background: '#1e293b',
            }}
          >
            <img
              src={item.media[0].poster || item.media[0].src}
              style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
            />
          </button>
        ))}
      </div>

      <ReelPlayerOverlay
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        content={content}
        initialIndex={initialIndex}
      />
    </div>
  );
}

完整流程,即从缩略图网格打开播放器、关闭后回到用户停下的位置,请查看从网格到播放器示例。

自定义

泛型内容类型

通过扩展 BaseContentItem:

tsx
import { ReelPlayerOverlay, type BaseContentItem } from '@reelkit/react-reel-player';

interface MyItem extends BaseContentItem {
  title: string;
  username: string;
}

const items: MyItem[] = [
  {
    id: '1',
    media: [{ id: 'v1', type: 'video', src: '/video.mp4', aspectRatio: 9/16 }],
    title: 'My Video',
    username: '@user',
  },
];

<ReelPlayerOverlay<MyItem>
  isOpen={isOpen}
  onClose={handleClose}
  content={items}
  renderSlideOverlay={(item) => (
    <div style={{ position: 'absolute', bottom: 16, left: 16, color: '#fff' }}>
      <strong>{item.username}</strong>
      <p>{item.title}</p>
    </div>
  )}
/>

自定义幻灯片浮层

用每张幻灯片自己的内容替换内置的幻灯片浮层:

tsx
<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={handleClose}
  content={content}
  renderSlideOverlay={(item, index, isActive) => (
    <div style={{
      position: 'absolute',
      bottom: 0,
      left: 0,
      right: 0,
      padding: 16,
      background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
      color: '#fff',
    }}>
      <h3>{item.author.name}</h3>
      <p>{item.description}</p>
      <span>Slide {index + 1} {isActive ? '(active)' : ''}</span>
    </div>
  )}
/>

非媒体幻灯片

使用 renderSlide 注入自定义内容(例如行动号召卡片)。返回 null 则回退到默认实现:

tsx
<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={handleClose}
  content={content}
  renderSlide={({ index, size }) => {
    // CTA card on last slide
    if (index === content.length - 1) {
      return (
        <div style={{
          width: size[0],
          height: size[1],
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          background: 'linear-gradient(135deg, #667eea, #764ba2)',
          color: '#fff',
        }}>
          <div style={{ textAlign: 'center' }}>
            <h2>Follow for more!</h2>
            <button>Subscribe</button>
          </div>
        </div>
      );
    }
    // Fall back to default MediaSlide + overlay
    return null;
  }}
/>

自定义控件

把可复用的子组件和你自己的东西组合起来:

tsx
import {
  ReelPlayerOverlay,
  CloseButton,
  SoundButton,
} from '@reelkit/react-reel-player';

<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={handleClose}
  content={content}
  renderControls={({ onClose, content, activeIndex }) => (
    <>
      <CloseButton onClick={onClose} />
      <SoundButton />
      <button
        onClick={() => share(content[activeIndex])}
        style={{
          position: 'absolute',
          bottom: 60,
          right: 16,
          zIndex: 10,
        }}
      >
        Share
      </button>
    </>
  )}
/>

自定义时间轴

用你自己的拖动界面替换内置的播放条,方式是 renderTimeline. The callback only fires when the overlay's gating rules would render the default bar (same timeline 模式 + timelineMinDurationSeconds 的逻辑),所以不必自己重写一遍。在你的根元素上复用 .rk-reel-timeline 类,即可继承贴底定位、安全区内边距,以及触摸设备上为幻灯片浮层预留的空间。

tsx
import { useRef, useEffect } from 'react';
import { ReelPlayerOverlay } from '@reelkit/react-reel-player';
import { Observe } from '@reelkit/react';

function CustomTimelineBar({ timelineState }) {
  const trackRef = useRef(null);
  useEffect(() => {
    if (!trackRef.current) return;
    // Pointer + keyboard scrub wiring, same as the built-in bar.
    return timelineState.bindInteractions(trackRef.current);
  }, [timelineState]);

  return (
    <div className="rk-reel-timeline" style={{ padding: '0 16px' }}>
      <Observe signals={[timelineState.progress, timelineState.currentTime]}>
        {() => (
          <div
            ref={trackRef}
            role="slider"
            aria-valuenow={timelineState.currentTime.value}
            style={{ height: 6, background: 'rgba(255,255,255,0.2)' }}
          >
            <div style={{
              width: `${timelineState.progress.value * 100}%`,
              height: '100%',
              background: 'linear-gradient(90deg, #6366f1, #ec4899)',
            }} />
          </div>
        )}
      </Observe>
    </div>
  );
}

<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={handleClose}
  content={content}
  timeline="always"
  renderTimeline={({ timelineState }) => (
    <CustomTimelineBar timelineState={timelineState} />
  )}
/>

自定义导航

tsx
<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={handleClose}
  content={content}
  renderNavigation={({ onPrev, onNext, activeIndex, count }) => (
    <div style={{ position: 'absolute', right: 16, top: '50%', transform: 'translateY(-50%)' }}>
      <button onClick={onPrev} disabled={activeIndex === 0}>Up</button>
      <span>{activeIndex + 1}/{count}</span>
      <button onClick={onNext} disabled={activeIndex === count - 1}>Down</button>
    </div>
  )}
/>

自定义嵌套导航

用自定义导航替换多媒体幻灯片(横向轮播)内部的左右箭头:

tsx
<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={handleClose}
  content={content}
  renderNestedNavigation={({ onPrev, onNext, activeIndex, count }) => (
    <div style={{
      position: 'absolute',
      bottom: 48,
      left: 0,
      right: 0,
      display: 'flex',
      justifyContent: 'center',
      gap: 8,
      zIndex: 10,
    }}>
      <button onClick={onPrev} disabled={activeIndex === 0}>Prev</button>
      <span>{activeIndex + 1} / {count}</span>
      <button onClick={onNext} disabled={activeIndex === count - 1}>Next</button>
    </div>
  )}
/>

自定义嵌套幻灯片

用 renderNestedSlide定制多媒体轮播内部的单张幻灯片。用 props.defaultContent 包裹默认的 ImageSlide/VideoSlide,或者完全替换它:

tsx
// Wrap default slides with rounded corners
<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={handleClose}
  content={content}
  renderNestedSlide={({ defaultContent }) => (
    <div style={{ borderRadius: 16, overflow: 'hidden' }}>
      {defaultContent}
    </div>
  )}
/>

// Fully custom nested slide for images
<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={handleClose}
  content={content}
  renderNestedSlide={({ item, size, isActive, slideKey, onVideoRef, defaultContent }) => {
    if (item.type === 'video') return defaultContent; // keep default video
    return (
      <ImageSlide
        src={item.src}
        size={size}
        imgStyle={{ objectFit: 'contain' }}
        style={{ backgroundColor: '#111' }}
      />
    );
  }}
/>

URL 状态

查看在线演示 →

ReelPlayerUrlOverlay 是一个独立组件,它的打开状态存放在地址栏里。用 useOverlayUrlState from @reelkit/react 构建控制器,再作为 controller传进去:参数指向某张幻灯片时播放器自己打开,参数消失时关闭。链接可以分享,返回键关闭的是播放器而不是离开页面。

内置的 key

可以用内置的 key 来寻址幻灯片 —— 把 urlIndexKey (按位置)或 urlStableIdKey (按稳定的 id)展开进控制器 —— 两者都从 @reelkit/react. See the URL 状态指南 和 核心 API.

tsx
import { useOverlayUrlState, urlIndexKey, urlStableIdKey } from '@reelkit/react';
import { ReelPlayerUrlOverlay } from '@reelkit/react-reel-player';
import { Link } from 'react-router-dom';

const reel = useOverlayUrlState({
  param: 'reel',
  ...urlIndexKey(() => content.length),
});

// Opening is a link — the overlay reads the URL and opens itself.
{content.map((item, i) => (
  <Link key={item.id} to={`?reel=${i}`}>
    <img src={getThumbnail(item)} />
  </Link>
))}

<ReelPlayerUrlOverlay controller={reel} content={content} />

完整的 useOverlayUrlState 选项 —— param, adapter, codec, locator —— 见 React API 参考。完整讲解见 React 指南.

  • 打开时压入 一条 历史记录。滑动信息流则是 替换 它,因此滑 N 次也不会多出记录,退一步永远就是离开播放器。返回键关闭播放器,不会逐张后退。
  • 只有在应用内部打开播放器时返回键才会关闭它 —— 因为那次链接压入了一条记录。在新标签页里直接打开的分享链接背后没有历史,浏览器返回会离开站点;这时用 ✕ 按钮或 Escape 就地移除参数并留在页面上。
  • 深链 ?reel=3 会在加载时直接把播放器打开到那一张。
  • 指向不存在幻灯片的参数 —— 过期的书签、手改的值 —— 会从 URL 中移除,而不是让地址栏继续声称一张打不开的幻灯片。
  • 默认情况下参数寻址的是 竖向 的帖子(?reel=3)。改用双轴 key 还能同时携带多媒体轮播的内层媒体索引 —— 见下文。

一条轴还是两条 —— 自己决定 URL 的深度

同一个 ReelPlayerUrlOverlay 两种形态都能驱动;它在运行时根据控制器的 position 自行判别,所以没有 mode 属性。在构建控制器时选好 key 即可:

KeyURL 形态携带内容
urlIndexKey(…)?reel=3只有竖向的帖子。
urlIndexTwoAxisKey(…)?reel=3.2帖子 和 轮播内层媒体索引。

两种形态是刻意区分开的 —— 双轴 key 严格使用点分隔(3.0,绝不会是裸的 3),因此单轴链接不会被错误解码。也正因如此,应用在两种 key 之间切换会让此前分享出去的链接全部失效。选定一种形态就别再改。

tsx
import { useOverlayUrlState, urlIndexTwoAxisKey } from '@reelkit/react';

const reel = useOverlayUrlState({
  param: 'reel',
  ...urlIndexTwoAxisKey({
    outerCount: () => content.length,
    innerCounts: () => content.map((post) => post.media.length),
  }),
});

// A link now names both axes: post 3, inner media 2.
<Link to="?reel=3.2">…</Link>
<ReelPlayerUrlOverlay controller={reel} content={content} />

带路由的应用 —— 请传入适配器。 绕过路由器直接写 history.pushState 会让它的 location 过期,下一次导航就会把参数丢掉:

tsx
import { useReactRouterUrlAdapter } from '@reelkit/react/react-router-url-adapter';

const adapter = useReactRouterUrlAdapter();
const reel = useOverlayUrlState({
  param: 'reel',
  adapter,
  ...urlIndexKey(() => content.length),
});

<ReelPlayerUrlOverlay controller={reel} content={content} />

稳定的链接。 索引是按位置的,所以收藏下来的 ?reel=3 在信息流重新排序后就会打开另一条帖子 —— 对信息流来说这是常态,而不是例外。 urlStableIdKey 按每条帖子稳定的 id来寻址,扫描当前的信息流 —— 一次调用就覆盖了常见场景。

tsx
const reel = useOverlayUrlState({
  param: 'reel',
  ...urlStableIdKey({ items: () => content }),
});

传入 hashCodec: base64UrlCodec 即可把 URL 中的 id 做 base64url 编码 —— 这是可逆的混淆,不是加密哈希。

想按别的字段(比如 slug)来寻址,或者用 locateAsync给无限信息流翻页,就自己构建 codec/locator 。这是两件事: codec 把身份写进 URL, locator 则负责找到这个身份在哪。

tsx
const reel = useOverlayUrlState({
  param: 'reel',
  codec: { decode: (raw) => raw, encode: (id) => id },
  locator: {
    locate: (id) => content.findIndex((x) => x.id === id),
    identify: (index) => content[index].id,
  },
});

无限信息流。 locate 是同步的,因此只能回答已经加载过的帖子 —— 只加载了 20 条时,指向第 400 条的分享链接就查不到。 locateAsync 是兜底,只有在 locate 未命中时才调用。

快捷键

只想按条目的 id来寻址?那就不必手写编解码器和定位器 —— 直接把 locateAsync 传给 urlStableIdKey({ items, locateAsync }) (未命中时它会去拉取,然后返回索引)。下面更完整的写法是给按别的字段寻址、或者需要完全掌控的场景准备的。

tsx
const reel = useOverlayUrlState({
  param: 'reel',
  codec: { decode: (raw) => raw, encode: (id) => id },
  locator: {
    locate: (id) => content.findIndex((x) => x.id === id),
    identify: (index) => content[index].id,
    locateAsync: async (id) => {
      const loaded = await loadById(id); // or loadUntil(id) — fetch just that one, or page up to it
      if (!loaded) return null; // exhausted — link names no post
      setContent(loaded); // commit — the overlay renders from this state
      return loaded.findIndex((x) => x.id === id); // wherever it landed
    },
  },
});
  • 在 locateAsync 未完成期间,播放器保持关闭,参数也不动,因此深链能熬过这次请求。 null 或请求被拒绝则会移除参数。
  • 如果结果在 URL 已经变化、播放器已关闭或组件已卸载之后才到达,就会被丢弃 —— 慢请求不能打开一张没人要的幻灯片。
  • 等待期间什么都不渲染;加载状态本来就归页面自己管,所以请渲染你自己的骨架屏。
  • 没有超时机制 —— 播放器无从得知信息流有多长。分页用尽时请以 null 结束,否则浮层会一直关着。

API 参考

ReelPlayerOverlayProps Props

ReelPlayerOverlayProps<T>

ReelPlayerOverlayProps 由 ReelPlayerOverlayBaseProps(两个浮层共用的视觉和行为属性)与 ReelPlayerControlledProps(isOpen 和 onClose)组合而成。为可渲染任一浮层的包装组件标注类型时,使用这个基础类型。

属性类型默认值说明
apiRefMutableRefObject<ReelApi>-用于访问 Reel API 的 ref
ariaLabelstring'Video player'对话框区域的无障碍标签;浮层打开时由屏幕阅读器播报
aspectRationumber9/16 (0.5625)桌面端播放器容器的宽高比。宽度在 768px 及以下时,播放器始终占满整个视口。为零、为负或非有限的值会回退到 9/16。
contentT[]必填内容条目数组(泛型,默认为 ContentItem)
initialIndexnumber0起始幻灯片索引
initialInnerIndexnumber0打开时定位的内层媒体索引,只对最初可见的那条帖子生效 —— 让双轴 URL 能直达多媒体帖子里的某一张图。播放器打开后用户一开始导航就会忽略它。
isOpenboolean必填控制浮层的显示。如果希望由 URL 驱动打开状态,请改用独立的 ReelPlayerUrlOverlay —— 见下面的 URL 状态。
timeline'auto' | 'always' | 'never''auto'内置播放进度条的显示策略。'auto' 只在视频时长超过 timelineMinDurationSeconds 时渲染;'always' 只要当前幻灯片有视频就渲染;'never' 关闭内置进度条(需要完全自定义时使用 renderTimeline)。
timelineMinDurationSecondsnumber30timeline='auto' 时渲染内置进度条所需的最短视频时长(秒)。低于该阈值的循环短片不显示进度条。
renderControls(props: ControlsRenderProps) => ReactNode-自定义控件,替换默认的关闭 + 声音按钮
renderError(props: { item: T; activeIndex: number }) => ReactNode-自定义错误提示,替换默认的错误图标
renderLoading(props: { item: T; activeIndex: number }) => ReactNode-自定义加载提示,替换默认的波浪加载动画
renderNavigation(props: NavigationRenderProps) => ReactNode-自定义导航,替换默认的竖向箭头
renderNestedNavigation(props: NavigationRenderProps) => ReactNode-嵌套横向滑动器(多媒体帖子)的自定义导航,替换默认的左右箭头
renderNestedSlide(props: NestedSlideRenderProps) => ReactNode-嵌套横向滑动器条目的自定义渲染器。用 props.defaultContent 包裹或嵌入默认的 ImageSlide/VideoSlide。与 renderSlide 不同,这里返回 null 不会回退到默认实现。
renderSlide(props: SlideRenderProps) => ReactNode | null-自定义幻灯片渲染。返回 null 则回退到默认实现。用 props.defaultContent 包裹或嵌入默认幻灯片。
renderSlideOverlay(item, index, isActive) => ReactNode-每张幻灯片的自定义浮层,替换默认的 SlideOverlay。返回 null 则隐藏。
renderTimeline(props: TimelineRenderProps) => ReactNode-自定义播放时间轴条。只有在门控规则会渲染默认条时才会调用(同样是 auto/always/never + timelineMinDurationSeconds 的逻辑)。用 props.defaultContent 包裹内置的 <TimelineBar />;返回 null 则隐藏。

ReelPlayerUrlOverlay 属性

ReelPlayerUrlOverlayProps<T>

接受上面所有视觉和行为属性,除了 isOpen,它被 controller 取代:类型是 ReelPlayerOverlayBaseProps 与 ReelPlayerUrlControlledProps 的组合。initialIndex 会被忽略 —— 由控制器的 position 决定打开哪一张,所以同时传入的值每次打开都会被覆盖。

属性类型默认值说明
controllerUrlStateController必填来自 useOverlayUrlState 的控制器。它的 position 决定浮层是否打开、显示哪一张;浮层会在切换幻灯片和关闭时通过它写回。

回调

属性类型说明
onClose() => void播放器关闭时调用。在 ReelPlayerOverlay 上是必填的(打开状态归你管,所以关闭也得你处理);在 ReelPlayerUrlOverlay 上是可选的,那里由 URL 驱动关闭 —— 只在你需要关闭后做点什么时才传。
onSlideChange(index: number) => void幻灯片切换后调用
onInnerSlideChange(outerIndex: number, innerIndex: number) => void当前帖子的内层媒体索引变化时调用 —— 包括多媒体帖子内的内层导航和外层激活,报告被激活帖子当前的内层索引(单媒体帖子为 0)。

Reel 属性(透传)

这些属性会转发给底层的 Reel 组件。它们的类型是 ReelProxyProps,即浮层透传的那部分 Reel 属性;用它为转发这些属性的包装组件标注类型。

属性类型默认值说明
enableNavKeysbooleantrue启用键盘导航
enableWheelbooleantrue启用鼠标滚轮导航
loopbooleanfalse启用无限循环
swipeDistanceFactornumber0.12滑动阈值(0-1)
transitionDurationnumber300过渡动画时长(毫秒)
wheelDebounceMsnumber200滚轮防抖时长(毫秒)

类型

BaseContentItem

泛型约束类型。扩展它即可在 ReelPlayerOverlay 中使用自定义数据模型。

typescript
interface BaseContentItem {
  id: string;
  media: MediaItem[];
}

ContentItem

typescript
interface ContentItem extends BaseContentItem {
  author: {
    name: string;
    avatar: string;
  };
  likes: number;
  description: string;
}

MediaItem

typescript
interface MediaItem {
  id: string;
  type: 'image' | 'video';
  src: string;
  poster?: string;
  aspectRatio: number;
}

MediaType

typescript
type MediaType = 'image' | 'video';

ControlsRenderProps<T>

typescript
interface ControlsRenderProps<T extends BaseContentItem> {
  onClose: () => void;
  soundState: SoundState;
  activeIndex: number;
  content: T[];
}
typescript
interface NavigationRenderProps {
  onPrev: () => void;
  onNext: () => void;
  activeIndex: number;
  count: number;
}

SlideRenderProps<T>

typescript
interface SlideRenderProps<T extends BaseContentItem> {
  item: T;
  index: number;
  size: [number, number];
  isActive: boolean;
  slideKey: string;
  onVideoRef?: (ref: HTMLVideoElement | null) => void;
  innerSliderRef: MutableRefObject<ReelApi | null>;
  onActiveMediaTypeChange?: (type: 'image' | 'video') => void;
  renderNestedNavigation?: (props: NavigationRenderProps) => ReactNode;
  enableWheel?: boolean;
  defaultContent: ReactNode;
}

NestedSlideRenderProps

typescript
interface NestedSlideRenderProps {
  item: MediaItem;
  index: number;
  size: [number, number];
  isActive: boolean;
  isInnerActive: boolean;
  slideKey: string;
  onVideoRef?: (ref: HTMLVideoElement | null) => void;
  defaultContent: ReactNode;
}

SlideOverlayProps

typescript
interface SlideOverlayProps {
  author?: { name: string; avatar: string };
  description?: string;
  likes?: number;
}

ImageSlideProps

typescript
interface ImageSlideProps {
  src: string;
  size: [number, number];
  className?: string;
  style?: CSSProperties;
  imgClassName?: string;
  imgStyle?: CSSProperties;
}

VideoSlideProps

typescript
interface VideoSlideProps {
  src: string;
  poster?: string;
  aspectRatio: number;
  size: [number, number];
  isActive: boolean;
  isInnerActive?: boolean;   // default: true
  slideKey: string;
  onVideoRef?: (ref: HTMLVideoElement | null) => void;
  className?: string;
  style?: CSSProperties;
}

CloseButtonProps

typescript
interface CloseButtonProps {
  onClick: () => void;
  className?: string;
  style?: React.CSSProperties;
}

SoundButtonProps

typescript
interface SoundButtonProps {
  disabled?: boolean;
  className?: string;
  style?: React.CSSProperties;
}

TimelineBarProps

typescript
interface TimelineBarProps {
  className?: string;
  style?: React.CSSProperties;
}

TimelineRenderProps<T>

typescript
interface TimelineRenderProps<T extends BaseContentItem> {
  item: T;
  activeIndex: number;
  timelineState: TimelineController;
  defaultContent: ReactNode;
}

子组件

对外导出的可复用积木,用于在自定义 render props 中组合:

CloseButton

带默认播放器样式的独立关闭按钮。在 renderControls.

tsx
import { CloseButton } from '@reelkit/react-reel-player';

<CloseButton onClick={onClose} />
<CloseButton onClick={onClose} className="my-close-btn" style={{ top: 24, right: 24 }} />

SoundButton

独立的声音开关。必须位于 SoundProvider 内部(ReelPlayerOverlay 会自动提供)。

tsx
import { SoundButton } from '@reelkit/react-reel-player';

<SoundButton />
<SoundButton disabled className="my-sound-btn" />

TimelineBar

默认的播放拖动条。它读取最近的 TimelineProvider (automatically mounted inside ReelPlayerOverlay内部自动挂载),并渲染轨道、缓冲区间、进度填充和拖动手柄。通过 --rk-reel-timeline-* 自定义属性做主题定制,或者用 renderTimeline.

tsx
import { TimelineBar } from '@reelkit/react-reel-player';

// Inside renderTimeline — wrap or augment the default bar:
<ReelPlayerOverlay
  renderTimeline={({ defaultContent }) => (
    <>
      <MyTimecode />
      {defaultContent}
    </>
  )}
/>

// Or render standalone inside a custom TimelineProvider tree:
<TimelineBar className="my-timeline" />

SlideOverlay

默认的渐变浮层,显示作者、描述和点赞。当内容具备所需字段时自动渲染。用 renderSlideOverlay 可以替换或隐藏它。

tsx
import { SlideOverlay } from '@reelkit/react-reel-player';

<SlideOverlay
  author={{ name: 'John', avatar: '/avatar.jpg' }}
  description="Amazing content"
  likes={12500}
/>

ImageSlide

带懒加载的图片幻灯片,默认使用 object-fit: cover 。在 renderSlide 内部使用它,即可用你自己的样式组合出自定义图片幻灯片。

tsx
import { ImageSlide } from '@reelkit/react-reel-player';

// Default usage
<ImageSlide src="/photo.jpg" size={[400, 700]} />

// Custom styles
<ImageSlide
  src="/photo.jpg"
  size={[400, 700]}
  className="my-image-slide"
  style={{ backgroundColor: '#1a1a1a', borderRadius: 12 }}
  imgStyle={{ objectFit: 'contain' }}
/>

VideoSlide

视频幻灯片,使用共享的 <video> 元素以保证 iOS 上声音连续,并支持封面帧、位置记忆和加载提示。必须位于 SoundProvider 内部(ReelPlayerOverlay 会自动提供)。

tsx
import { VideoSlide } from '@reelkit/react-reel-player';

<VideoSlide
  src="/video.mp4"
  poster="/thumb.jpg"
  aspectRatio={9 / 16}
  size={[400, 700]}
  isActive={true}
  slideKey="slide-1"
  style={{ borderRadius: 12 }}
/>
组合自定义幻灯片

使用 renderSlide ,并带 ImageSlide / VideoSlide 即可在保留全部内置行为(自动播放、封面抽帧、声音同步)的前提下自定义媒体渲染。

tsx
import {
  ReelPlayerOverlay,
  ImageSlide,
  VideoSlide,
} from '@reelkit/react-reel-player';

<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={handleClose}
  content={content}
  renderSlide={({ item, size, isActive, slideKey, onVideoRef }) => {
    const media = item.media[0];
    if (media.type === 'image') {
      return (
        <ImageSlide
          src={media.src}
          size={size}
          imgStyle={{ objectFit: 'contain' }}
          style={{ backgroundColor: '#111' }}
        />
      );
    }
    if (media.type === 'video') {
      return (
        <VideoSlide
          src={media.src}
          poster={media.poster}
          aspectRatio={media.aspectRatio}
          size={size}
          isActive={isActive}
          slideKey={slideKey}
          onVideoRef={onVideoRef}
          style={{ borderRadius: 16 }}
        />
      );
    }
    return null; // fallback to default
  }}
/>

内容加载与错误处理

播放器会逐张跟踪加载和错误状态。内容加载时显示波浪加载动画;媒体损坏时显示错误图标。出错的 URL 会被缓存,再次访问时立刻显示错误而不重试。

生命周期回调

使用 renderSlide时,请调用这些回调来控制加载提示:

回调何时调用
onReady图片已加载,或视频已开始播放。会清除加载和错误状态。
onWaiting视频在播放途中正在缓冲。显示加载提示。
onError内容加载失败。显示错误浮层,并把该 URL 标记为损坏缓存起来。
tsx
// Inside renderSlide — wire callbacks to your custom media
renderSlide={({ item, size, isActive, onReady, onWaiting, onError }) => (
  <div style={{ width: size[0], height: size[1] }}>
    {item.media[0].type === 'image' ? (
      <img
        src={item.media[0].src}
        onLoad={onReady}
        onError={onError}
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    ) : (
      <video
        src={item.media[0].src}
        autoPlay={isActive}
        onCanPlay={onReady}
        onWaiting={onWaiting}
        onError={onError}
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    )}
  </div>
)}

自定义加载与错误界面

用自定义组件替换默认的波浪加载动画和错误图标:

tsx
<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={() => setIsOpen(false)}
  content={content}
  renderLoading={({ item, activeIndex }) => (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 10,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      color: '#fff', fontSize: 14,
    }}>
      Loading slide {activeIndex + 1}...
    </div>
  )}
  renderError={({ item, activeIndex }) => (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 10,
      display: 'flex', flexDirection: 'column',
      alignItems: 'center', justifyContent: 'center',
      gap: 12, color: 'rgba(255,255,255,0.5)',
    }}>
      <span style={{ fontSize: 48 }}>!</span>
      <span>Failed to load media</span>
    </div>
  )}
/>

时间轴

浮层会在当前视频上方渲染一个内置的播放时间轴条。 timeline 属性(类型为 TimelineMode)控制它是否渲染:

  • 'auto' (默认):当前媒体是时长超过 timelineMinDurationSeconds (默认 30)的视频时渲染。单视频幻灯片和多媒体轮播都适用;进度条会跟随当前的内层条目,遇到图片则隐藏。
  • 'always':只要当前幻灯片有视频就渲染。
  • 'never':永不渲染。请通过 renderTimeline.
tsx
<ReelPlayerOverlay
  isOpen={isOpen}
  onClose={close}
  content={items}
  timeline="auto"
  timelineMinDurationSeconds={30}
/>

通过 --rk-reel-timeline-* CSS 自定义属性做主题定制(高度、颜色、光标大小)。若要完全自定义拖动条、时间码或进度指示,请用 renderTimeline;回调会收到一个 timelineState ,其数据来自底层的 TimelineController.

浮层内部渲染的组件也可以自己读取同一个控制器:useTimelineState() 从最近的 TimelineProvider(由浮层挂载)返回它,在其外部会抛出错误;useTimelineStateOptional() 在那种情况下返回 null,适合也可能在播放器外部渲染的组件。

声音上下文

自定义实现时,你可以访问声音状态:

tsx
import { SoundProvider, useSoundState } from '@reelkit/react';

// ReelPlayerOverlay wraps itself in a SoundProvider.
// Access sound state inside custom controls:
function CustomControls() {
  const soundState = useSoundState();

  return (
    <button onClick={soundState.toggle}>
      {soundState.muted.value ? 'Unmute' : 'Mute'}
    </button>
  );
}

CSS 类名

所有 CSS 类名都是普通类名(不是 CSS Modules),因此可以在 @reelkit/react-reel-player/styles.css之后加载的样式表里用更高优先级的选择器覆盖它们。若只是改颜色、尺寸和 z-index,请优先使用下面 主题定制 一节记录的 CSS 自定义属性 —— 它们正是为此设计的。

类名组件说明
.rk-reel-overlayOverlay固定的全屏背景层(背景、z-index)
.rk-reel-containerOverlay播放器容器(定位、溢出)
.rk-reel-loaderOverlay波浪加载动画浮层
.rk-reel-media-errorOverlay错误状态浮层(居中图标 + 文字)
.rk-reel-media-error-textOverlay错误信息文字
.rk-reel-button控制内容共用的圆形图标按钮(关闭、声音、导航箭头)
.rk-reel-close-btn控制内容关闭按钮
.rk-reel-sound-btn控制内容声音开关按钮
.rk-reel-nav-arrows导航仅桌面端的箭头容器(宽度在 768px 及以下时隐藏)
.rk-reel-nav-button导航单个上一张 / 下一张导航箭头
.rk-reel-slide-wrapperSlide媒体 + 浮层的包装层
.rk-reel-slide-overlaySlideOverlay渐变浮层容器
.rk-reel-slide-overlay-authorSlideOverlay作者行(头像 + 名称)
.rk-reel-slide-overlay-avatarSlideOverlay作者头像图片
.rk-reel-slide-overlay-nameSlideOverlay作者名称文字
.rk-reel-slide-overlay-descriptionSlideOverlay描述文字
.rk-reel-slide-overlay-likesSlideOverlay点赞行(爱心 + 数量)
.rk-reel-video-containerVideoSlide视频包装层(背景、溢出)
.rk-reel-video-elementVideoSlide<video> 元素
.rk-reel-video-posterVideoSlide封面图(播放时淡出)
.rk-reel-video-poster.rk-visibleVideoSlide视频暂停 / 加载时施加在封面图上的状态修饰类
.rk-reel-nested-indicatorNestedSlider多媒体幻灯片下方的圆点分页(桌面端与触摸端位置不同)
.rk-reel-nested-navNestedSlider横向轮播箭头(宽度在 768px 及以下时隐藏)
.rk-reel-nested-nav-nextNestedSlider嵌套的下一张箭头位置
.rk-reel-nested-nav-prevNestedSlider嵌套的上一张箭头位置
.rk-reel-timelineTimelineBar拖动条包装层。在自定义的 `renderTimeline` 根元素上复用它,即可继承贴底定位、安全区内边距,以及触摸设备上为幻灯片浮层预留的空间。
.rk-reel-timeline-trackTimelineBar轨道(未播放区域)
.rk-reel-timeline-bufferedTimelineBar缓冲分段层
.rk-reel-timeline-fillTimelineBar已播放进度填充
.rk-reel-timeline-cursorTimelineBar拖动手柄(浮在轨道上方)

主题定制

每一个颜色、尺寸、z-index 和过渡都放在 CSS 自定义属性里。在 :root (或浮层的任意祖先元素)上覆盖其中一个或多个,即可在不改组件源码的情况下换主题。

变量默认值控制内容
--rk-reel-overlay-bg#000全屏背景颜色
--rk-reel-overlay-z1000浮层 z-index
--rk-reel-button-bgrgba(0, 0, 0, 0.5)圆形按钮的默认背景
--rk-reel-button-bg-hoverrgba(255, 255, 255, 0.1)导航箭头背景(以及基础悬停状态)
--rk-reel-button-bg-hover-strongrgba(255, 255, 255, 0.2)导航箭头悬停背景
--rk-reel-button-fg#fff按钮图标颜色
--rk-reel-button-size44px按钮宽高
--rk-reel-button-radius50%按钮圆角半径
--rk-reel-ui-z10关闭、声音和导航按钮的 z-index
--rk-reel-edge-padding16px关闭、声音和导航箭头距边缘的内缩
--rk-reel-nav-gap8px堆叠导航箭头之间的间距
--rk-reel-transition0.2s悬停过渡时长
--rk-reel-loader-colorrgba(255, 255, 255, 0.12)波浪加载渐变颜色
--rk-reel-loader-duration1.8s波浪加载动画时长
--rk-reel-error-fgrgba(255, 255, 255, 0.4)错误图标和文字颜色
--rk-reel-error-text-size13px错误信息字号
--rk-reel-slide-overlay-bglinear-gradient(transparent, rgba(0, 0, 0, 0.7))说明文字遮罩渐变
--rk-reel-slide-overlay-padding48px 16px 16px说明文字内边距
--rk-reel-slide-overlay-name-color#fff作者名称颜色
--rk-reel-slide-overlay-description-colorrgba(255, 255, 255, 0.9)描述文字颜色
--rk-reel-slide-overlay-likes-colorrgba(255, 255, 255, 0.8)点赞行文字颜色
--rk-reel-video-bg#000<video> 后方的留黑背景
--rk-reel-nested-button-bgrgba(0, 0, 0, 0.5)内层箭头背景
--rk-reel-nested-button-bg-hoverrgba(255, 255, 255, 0.2)内层箭头悬停背景
--rk-reel-nested-button-size36px内层箭头尺寸
--rk-reel-nested-edge-padding12px内层箭头距边缘的内缩
--rk-reel-timeline-trackrgba(255, 255, 255, 0.22)轨道背景(未播放区域)
--rk-reel-timeline-bufferedrgba(255, 255, 255, 0.4)已缓冲片段颜色
--rk-reel-timeline-fill#fff已播放进度的填充颜色
--rk-reel-timeline-cursor#fff拖动手柄胶囊颜色
--rk-reel-timeline-height3px轨道静止时的高度
--rk-reel-timeline-height-active6px悬停、聚焦或拖动时的轨道高度
--rk-reel-timeline-cursor-width10px拖动胶囊静止时的宽度
--rk-reel-timeline-cursor-width-active14px拖动时胶囊的宽度
--rk-reel-timeline-cursor-height24px拖动胶囊静止时的高度
--rk-reel-timeline-cursor-height-active32px拖动时胶囊的高度
--rk-reel-timeline-hitbox16px进度轨道上方额外的指针命中区域
--rk-reel-timeline-transition0.15s ease-out轨道与胶囊的伸缩动画
--rk-reel-timeline-z11进度条 z-index(高于默认 UI 层)

把下面这段放进在 @reelkit/react-reel-player/styles.css.

css
/* Brand the reel-player overlay */
:root {
  --rk-reel-overlay-bg: #0f172a;
  --rk-reel-button-bg: rgba(99, 102, 241, 0.65);
  --rk-reel-button-bg-hover-strong: rgba(168, 85, 247, 0.85);
  --rk-reel-edge-padding: 24px;
  --rk-reel-button-size: 52px;

  /* Timeline bar: brand-matched, beefier on desktop */
  --rk-reel-timeline-track: rgba(99, 102, 241, 0.25);
  --rk-reel-timeline-buffered: rgba(168, 85, 247, 0.45);
  --rk-reel-timeline-fill: #a855f7;
  --rk-reel-timeline-cursor: #a855f7;
  --rk-reel-timeline-height: 4px;
  --rk-reel-timeline-height-active: 8px;
  --rk-reel-timeline-cursor-width-active: 18px;
  --rk-reel-timeline-transition: 0.2s ease-out;
}

无障碍

浮层根节点是一个模态对话框(role="dialog", aria-modal="true")。设置 ariaLabel 可以改变屏幕阅读器的播报内容,默认是 “Video player”。每张幻灯片都带有 role="group", aria-roledescription="slide",以及 aria-label="第 N 张,共 M 张",因此滑动时会播报当前在序列中的位置。

浮层打开时捕获焦点,关闭时把焦点还给触发元素。Tab 和 Shift+Tab 在内部的可聚焦元素之间循环;跑出去的焦点(点击外部、程序化聚焦)会被拉回来。实现基于 captureFocusForReturn 和 createFocusTrap from @reelkit/core.

键盘快捷键

Key作用
ArrowUp上一张幻灯片
ArrowDown下一张幻灯片
ArrowLeft上一个媒体(内层滑动器中)
ArrowRight下一个媒体(内层滑动器中)
Escape关闭播放器