Stories Player

一个面向 React 的 Instagram 风格 Stories 播放浮层,基于 @reelkit/react-stories-player.

查看在线演示 →

特性

嵌套导航
点击切换 story,滑动切换分组
视频 story
自动播放,带声音开关
自动播放
每个 story 可配置计时
3D 过渡
立方体、翻转、淡入、缩放、滑动
进度条
基于 canvas 的分段进度
图片与视频
两种媒体类型都支持
虚拟化
DOM 里只有 3 张幻灯片
双击点赞
双击时的爱心动画
桌面端导航
桌面端的箭头按钮
story 圆环
Instagram 风格的头像圆环
泛型类型
用自定义数据扩展 StoryItem
Render Props
每个界面元素都可定制
URL 状态
可分享的 ?story=group.story 链接
已观看状态
已看圆环与续播跨刷新保留

安装

bash
npm i @reelkit/react-stories-player @reelkit/react lucide-react

别忘了引入样式:

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

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

快速上手

StoriesOverlay 组件渲染一个全屏 Stories Player。搭配 StoriesRingList 作为 Instagram 风格的入口。传入一组 StoriesGroup 对象,并用 isOpen.

tsx
import { useState } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';

const groups: StoriesGroup[] = [
  {
    author: {
      id: 'user-1',
      name: 'Alice',
      avatar: '/cdn/samples/avatars/avatar-06.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's1-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-001.jpg',
      },
      {
        id: 's1-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-002.jpg',
      },
      {
        id: 's1-3',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-003.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-2',
      name: 'Bob',
      avatar: '/cdn/samples/avatars/avatar-07.jpg',
    },
    stories: [
      {
        id: 's2-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-004.jpg',
      },
      {
        id: 's2-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-005.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-3',
      name: 'Charlie',
      avatar: '/cdn/samples/avatars/avatar-08.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's3-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-006.jpg',
      },
    ],
  },
];

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedGroup, setSelectedGroup] = useState(0);
  // What was seen: rings, resume and recording from one controller, kept in
  // localStorage across reloads.
  const [viewed] = useState(() =>
    createStoriesViewedStateController({
      storageKey: 'stories-seen',
      groups: () => groups,
    }),
  );

  const openStories = (groupIndex: number) => {
    setSelectedGroup(groupIndex);
    setIsOpen(true);
  };

  return (
    <div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
      <StoriesRingList groups={groups} viewed={viewed} onSelect={openStories} />

      <StoriesOverlay
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        groups={groups}
        initialGroupIndex={selectedGroup}
        viewed={viewed}
      />
    </div>
  );
}
StoriesPlayer.tsx
import { useState } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';

const groups: StoriesGroup[] = [
  {
    author: {
      id: 'user-1',
      name: 'Alice',
      avatar: '/cdn/samples/avatars/avatar-06.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's1-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-001.jpg',
      },
      {
        id: 's1-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-002.jpg',
      },
      {
        id: 's1-3',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-003.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-2',
      name: 'Bob',
      avatar: '/cdn/samples/avatars/avatar-07.jpg',
    },
    stories: [
      {
        id: 's2-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-004.jpg',
      },
      {
        id: 's2-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-005.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-3',
      name: 'Charlie',
      avatar: '/cdn/samples/avatars/avatar-08.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's3-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-006.jpg',
      },
    ],
  },
];

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedGroup, setSelectedGroup] = useState(0);
  // What was seen: rings, resume and recording from one controller, kept in
  // localStorage across reloads.
  const [viewed] = useState(() =>
    createStoriesViewedStateController({
      storageKey: 'stories-seen',
      groups: () => groups,
    }),
  );

  const openStories = (groupIndex: number) => {
    setSelectedGroup(groupIndex);
    setIsOpen(true);
  };

  return (
    <div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
      <StoriesRingList groups={groups} viewed={viewed} onSelect={openStories} />

      <StoriesOverlay
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        groups={groups}
        initialGroupIndex={selectedGroup}
        viewed={viewed}
      />
    </div>
  );
}

想要一排在刷新后仍记得已看内容的圆环,请查看快拍栏示例。

URL 状态

查看在线演示 →

StoriesUrlOverlay 是一个独立组件,它的打开状态存放在地址栏里。两条轴共用一个参数 —— ?story=<group>.<story> —— 于是正在播放的 story 就有了可分享、可收藏、能用返回键关闭的链接。用 useOverlayUrlState 和 urlIndexTwoAxisKey构建控制器,再作为 controller.

内置的 key

Stories 是双轴的,所以请把双轴 key 展开进控制器: urlIndexTwoAxisKey (分组和 story 都按位置)或 urlStableIdTwoAxisKey (分组按稳定的 id)—— 两者都从 @reelkit/react. See the URL 状态指南 和 核心 API.

tsx
import {
  StoriesUrlOverlay,
  useOverlayUrlState,
  urlIndexTwoAxisKey,
} from '@reelkit/react-stories-player';
import { Link } from 'react-router-dom';

const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
  }),
});

// Opening a user is a link — the overlay reads the URL and opens itself.
{groups.map((g, i) => (
  <Link key={g.author.id} to={`?story=${i}.0`}>{g.author.name}</Link>
))}

<StoriesUrlOverlay controller={stories} groups={groups} />
  • 打开时压入 一条 历史记录。滑动 story 和 切换用户都是 替换 它,因此导航 N 次也不会多出记录,退一步永远就是关闭播放器。返回键关闭播放器,不会逐个后退 story。
  • 内层导航也会被记录。 story 索引不会被冻结在分组粒度上 —— 在某个用户的 story 之间前进时会更新 ?story=2.n ,因此深链能精确落到那一个 story。
  • 只有在应用内部打开播放器时返回键才会关闭它 —— 因为那次链接压入了一条记录。在新标签页里直接打开的分享链接背后没有历史,浏览器返回会离开站点;这时用 ✕ 按钮或 Escape 就地移除参数并留在页面上。
  • 指向不存在的分组或 story 的参数 —— 过期的书签、手改的值、超出分组末尾的 story —— 会从 URL 中移除,而不是打开相邻的那个。

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

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

const adapter = useReactRouterUrlAdapter();
const stories = useOverlayUrlState({
  param: 'story',
  adapter,
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
  }),
});

<StoriesUrlOverlay controller={stories} groups={groups} />

稳定的链接。 分组默认是按位置的,所以收藏下来的 ?story=2.0 在信息流重新排序后就会打开另一个用户。请改用稳定 id 来寻址分组 —— outerCodec 把 id 写进 URL, outerLocator 负责找到它在哪。同时传入 innerCodec 以及 innerLocate 和 innerIdentify,就能对分组内的 story 做同样的事;不传的话,story 那一半仍然是普通的局部索引。

tsx
const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
    // ?story=user_42.3
    outerCodec: { decode: (raw) => raw, encode: (id) => id },
    outerLocator: {
      locate: (id) => groups.findIndex((g) => g.author.id === id),
      identify: (index) => groups[index].author.id,
    },
    // Optional: ?story=user_42.story_7 — the story by id too
    innerCodec: { decode: (raw) => raw, encode: (id) => id },
    innerLocate: (outer, id) => {
      const index = groups[outer].stories.findIndex((s) => s.id === id);
      return index === -1 ? null : index;
    },
    innerIdentify: (outer, index) => groups[outer].stories[index].id,
  }),
});

无限信息流。 翻页是 outerLocator 该管的事,与编解码器无关。 locate 是同步的,因此只能回答已经加载过的分组 —— 只加载了 20 个时,指向第 400 个分组的分享链接就查不到。 locateAsync 是兜底,只有在 locate 未命中时才调用;story 会以最终落到的那个分组重新校准上界。

同一个 locateAsync,作用在外层轴

这和单轴 key 接受的 locateAsync 翻页器是同一个 —— 在双轴 key 上它跟随你传入的 outerLocator ,因此分组这条轴负责翻页,而 story 仍是解析出的分组内的局部索引。

tsx
const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
    outerLocator: {
      locate: (index) => (index < groups.length ? index : null),
      identify: (index) => index,
      locateAsync: async (index) => {
        const loaded = await loadUntilGroup(index); // page up to it
        if (!loaded) return null; // exhausted — link names no group
        setGroups(loaded); // commit — the overlay renders from this state
        return index;
      },
    },
  }),
});
  • 在 locateAsync 未完成期间,播放器保持关闭,参数也不动,因此深链能熬过这次请求。返回 null 或请求被拒绝则会移除参数。
  • 如果结果在 URL 已经变化、播放器已关闭或组件已卸载之后才到达,就会被丢弃 —— 慢请求不能打开一个没人要的 story。
  • 完整的 useOverlayUrlState 选项见 React API 参考,完整讲解见 React 指南.

记住已观看内容

圆环在分组看完之前显示渐变,分组重新打开时停在观看者尚未看过的第一条 story。两者都来自同一个 createStoriesViewedStateController:在 feed 所在的地方创建一次,作为 viewed 交给圆环列表和播放器,它们会自己读取存储、跟随它并记录看过的内容。已存记录按 id 记下作者和 story,因此能经受 feed 重排,而且同一个控制器在 Vue 和 Angular 中同样可用。

tsx
import { useRef, useState } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/react-stories-player';

function Feed({ groups }: { groups: StoriesGroup[] }) {
  const [open, setOpen] = useState(false);
  const [group, setGroup] = useState(0);

  // The controller reads the groups through a getter every time it needs
  // them, so keep the getter current: a ref for a plain prop, or a signal.
  const groupsRef = useRef(groups);
  groupsRef.current = groups;

  const [viewed] = useState(() =>
    createStoriesViewedStateController({
      storageKey: 'stories-seen',
      groups: () => groupsRef.current,
    }),
  );

  return (
    <>
      {/* The rings follow the controller by themselves. */}
      <StoriesRingList
        groups={groups}
        viewed={viewed}
        onSelect={(index) => {
          setGroup(index);
          setOpen(true);
        }}
      />

      {/* The player resumes, records and draws its card rings from it. */}
      <StoriesOverlay
        isOpen={open}
        onClose={() => setOpen(false)}
        groups={groups}
        initialGroupIndex={group}
        viewed={viewed}
      />
    </>
  );
}
  • 打开时的 story 也算。 挂载时就把屏幕上的 story 报告为已看,所以打开只有一条 story 的分组再关闭,也会标记为已看。
  • 往后滑同样会续播。 resumeStoryIndex 对本次会话中第一次到达的每个分组生效,包括播放器打开时所在的那个,除非 initialStoryIndex 显式指定了 story;已经滑过的分组会在离开处重新打开。
  • 链接仍然优先。 使用 StoriesUrlOverlay 时,由参数决定播放器在哪里打开,无论存了什么。其他情况下由续播回调决定。
  • 计数是位置,不是次数。 记录记的是到达的最远一条 story,因此给已看完的分组新增一条会重新点亮圆环,从中间删掉一条则会让计数变短。与分享链接得到的自愈相同。
  • 存储可替换。 传入 storage: createSessionStorageAdapter() 在关闭标签页时遗忘,或使用自己的 StorageAdapter。两个打开的标签页通过浏览器的 storage 事件保持一致。
  • 只在打开时才挂载的播放器读取得太晚。 播放器在一个效果中读取存储,而该效果在它仍然关闭时就已运行。使用 {open && <StoriesOverlay … />} 时没有这个时机,所以请在持有控制器的组件里调用 useAttachViewedState(viewed);它在挂载时接入,在卸载时释放。各选项以及它所基于的底层部件,见 Stories Core 页面。

设置 desktopLayout="carousel",播放器就会像 Instagram 网页版那样布局:当前故事保持原有尺寸居中,两侧各最多显示两个相邻分组,以较小、变暗的卡片呈现。点击卡片会打开对应分组,切换分组时卡片会横向滑动。传入与头像环列表相同的 viewed 控制器,已看完分组的卡片就会显示灰色的环。卡片会自行跟随它,因此故事被标记为已看时只有卡片重绘,播放器不会重新渲染。

tsx
// The same controller the ring list takes: the cards draw the same rings and
// repaint by themselves when a story is marked seen.
<StoriesOverlay
  isOpen={open}
  onClose={() => setOpen(false)}
  groups={groups}
  initialGroupIndex={group}
  desktopLayout="carousel"
  viewed={viewed}
/>;
  • 手机上仍是普通播放器。 宽度不超过 768px 时不渲染卡片,滑动使用 groupTransition,与默认的 'single' 布局一致。窗口宽度跨过这个值时布局会即时切换。
  • 卡片预览分组将要打开的位置:本次会话中离开时所在的故事,否则是 resumeStoryIndex 返回的故事。卡片显示视频封面或图片本身。两者都没有的故事(例如渐变背景上的文字故事)会由你的 renderSlide 按播放器尺寸绘制后再缩小;没有封面的视频显示卡片背景。这个预览以 isActive: false 调用 renderSlide,只是一张图:其中的任何内容都无法获得焦点或被点击。幻灯片在挂载时做的事情(例如播放或统计)请放在 isActive 判断之后。
  • 计时器会等滑动结束。 卡片停止移动之前,故事不会前进,也不会为它触发 onStoryViewed。
  • 所有切换分组的方式都会滑动:点击卡片、StoriesApi.goToGroup、nextGroup 和 prevGroup,以及在分组末尾继续按方向键。触摸滑动本身已经移动了播放器,因此不会再叠加滑动。

每个分组自带进度条和页眉

默认情况下,播放器上方只有一个进度条和一个页眉,分组切换完成后才换成新分组的内容。设置 chromePlacement="group" 后,每个分组都在自己的幻灯片里拥有一份,二者会像 Instagram 那样随分组一起转动。

tsx
// Every group carries its own progress bar and header, so both turn with the
// group instead of switching above the player once it has changed.
<StoriesOverlay
  isOpen={open}
  onClose={() => setOpen(false)}
  groups={groups}
  chromePlacement="group"
/>;
  • 相邻分组显示它停在哪里。 播放器转向它时,它的进度条和页眉显示它将打开的那个 story,尚未播放任何部分。
  • 正在离开的分组保持原样。 它的进度条在转动结束前保留原有进度,所以一个已播完的分组会保持填满,而不会在转动中途清空。
  • 屏幕上的每个分组都会调用自定义渲染器。 renderProgressBar 和 renderHeader 会收到 groupIndex 和 isActive;对相邻分组,isActive 为 false,进度信号保持不变。
  • 自定义页眉位于滑动区域内。 点击它会切换 story,除非点在 button、链接或带 role="button" 的元素上,所以其中的每个控件都应是这几类元素之一。
  • 桌面端轮播在滑动过程中不受影响。 卡片滑动时播放器是隐藏的,因此这个选项在分组打开后才会显现。

API 参考

StoriesOverlayProps

StoriesOverlayProps<T>

属性类型默认值说明
isOpenboolean必填控制浮层的显示。为 true 时会锁住 body 滚动。
groupsStoriesGroup<T>[]必填要展示的 story 分组数组
onClose() => void必填关闭浮层的回调
ariaLabelstring'Stories player'对话框区域的无障碍标签;浮层打开时由屏幕阅读器播报
initialGroupIndexnumber0初始可见分组的索引(从 0 开始)
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex),否则为 0分组内初始可见 story 的索引(从 0 开始)。显式指定时优先于任何记忆;省略则初始分组像其他分组一样恢复。
resumeStoryIndex(groupIndex: number) => number—分组第一次被打开时所在的 story,让观看者从上次离开处继续。仅对本次打开中尚未访问的分组生效。
groupTransitionTransitionTransformFncubeTransition外层(分组)滑动器的过渡效果
defaultImageDurationnumber5000图片类 story 的默认自动播放时长(毫秒)
tapZoneSplitnumber0.3点击区域的分割比例(0–1)。左侧触发上一个,右侧触发下一个。
hideUIOnPausebooleantrue长按暂停期间隐藏进度条和头部
enableKeyboardbooleantrue启用键盘导航(左右方向键、Escape)
innerTransitionDurationnumber200内层(story)过渡动画的时长(毫秒)
minSegmentWidthnumber8进度条分段的最小宽度(像素)
apiRefMutableRefObject<StoriesApi | null>-用于访问命令式 StoriesApi 的 ref
desktopLayout'single' | 'carousel''single'桌面端布局。'carousel' 在当前故事两侧以预览卡片显示相邻分组,并在分组之间滑动。手机上始终只显示故事本身。类型以 DesktopLayout 导出。
chromePlacement'overlay' | 'group''overlay'进度条和页眉放在哪里。'overlay' 在播放器上方绘制一份;'group' 在每个分组的幻灯片里各绘制一份,二者随分组一起转动。类型以 ChromePlacement 导出。
viewedStoriesViewedStateController-观看者已看过的内容,来自 createStoriesViewedStateController()。分组从第一个未看的 story 续播,每个显示过的 story 都会被记录,轮播卡片会为看完的分组显示灰色的环。把同一个控制器交给 StoriesRingList。
renderHeader(props: HeaderRenderProps<T>) => ReactNode-自定义页眉渲染器。接收作者、story 以及暂停 / 静音状态。使用 chromePlacement="group" 时页眉位于滑动区域内,参见每个分组自带进度条和页眉。
renderFooter(props: FooterRenderProps<T>) => ReactNode-自定义页脚渲染器。接收作者和 story 信息。
renderSlide(props: SlideRenderProps<T>) => ReactNode-自定义幻灯片渲染器,替换默认的图片 / 视频幻灯片。
renderNavigation(props: NavigationRenderProps) => ReactNode-自定义桌面端导航。替换默认的上一张 / 下一张箭头按钮。
renderProgressBar(props: ProgressBarRenderProps<T>) => ReactNode-自定义进度条。替换默认的 canvas 进度条。
renderLoading(props: LoadingRenderProps<T>) => ReactNode-自定义加载界面渲染器。不提供时显示默认的页眉转圈动画。
renderError(props: ErrorRenderProps<T>) => ReactNode-自定义错误界面渲染器。不提供时显示默认的错误图标浮层。
renderGroupPreview(props: GroupPreviewRenderProps<T>) => ReactNode-自定义桌面端轮播卡片。替换默认的卡片内容。

StoriesUrlOverlayProps

StoriesUrlOverlayProps<T>

接受 StoriesOverlay 的所有属性,除了打开状态那三个 —— isOpen, initialGroupIndex, initialStoryIndex —— 它们改由控制器提供。

属性类型默认值说明
controllerUrlStateController<TwoAxisPosition>必填来自 useOverlayUrlState 并展开了 urlIndexTwoAxisKey 的控制器。它的 position —— 一个 { outer, inner } 对象 —— 决定播放器是否打开、打开到哪里;浮层会在每次导航和关闭时写回。

回调

属性类型说明
onClose() => void播放器关闭时调用。在 StoriesOverlay 上是必填的(打开状态归你管,所以关闭也得你处理);在 StoriesUrlOverlay 上是可选的,那里由 URL 驱动关闭 —— 只在你需要关闭后做点什么时才传。
onStoryChange(groupIndex: number, storyIndex: number) => void当前 story 变化时触发
onGroupChange(groupIndex: number) => void当前分组变化时触发
onStoryViewed(groupIndex: number, storyIndex: number) => void某个 story 变为可见时触发
onStoryComplete(groupIndex: number, storyIndex: number) => void某个 story 的计时器走完时触发
onDoubleTap(groupIndex: number, storyIndex: number) => void双击手势时触发
onPause() => void播放器暂停时触发
onResume() => void播放器恢复时触发

过渡动画

groupTransition 属性控制在用户分组之间滑动时的 3D 过渡效果。过渡函数请从 @reelkit/react:

tsx
import {
  cubeTransition,   // default — 3D cube rotation
  flipTransition,   // card flip
  fadeTransition,   // crossfade
  zoomTransition,   // zoom in/out
  slideTransition,  // horizontal slide
} from '@reelkit/react';

<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  groupTransition={flipTransition}
/>

内容加载生命周期

每个 story 幻灯片都通过 SlideRenderProps:

回调何时
onReady内容就绪(图片已加载、视频在播放)。进度计时开始。
onWaiting内容卡住(视频播放途中缓冲)。显示转圈动画并暂停计时。
onError内容加载失败。显示错误浮层。
onDurationReady上报媒体的真实时长(例如来自视频元数据),以便用正确的时长重启计时。
onEnded表示媒体已播完(例如视频结束)。会前进到下一个 story。
预加载缓存

内置的 ImageStorySlide 和 VideoStorySlide 组件会在后台预加载下一个 story。用户切到已预加载的 story 时,内容会立刻出现,不会有加载动画。

Render Props

每个界面元素都可以通过 render props 替换。每个都会收到带类型的属性,包含所需的全部状态和回调。

renderHeader

替换默认页眉(作者信息、暂停 / 静音按钮、关闭按钮):

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderHeader={({ author, story, isPaused, isMuted, isVideo, onToggleSound, onTogglePause, onClose }) => (
    <div style={{
      position: 'absolute',
      top: 0,
      left: 0,
      right: 0,
      padding: 16,
      display: 'flex',
      alignItems: 'center',
      gap: 8,
      zIndex: 10,
    }}>
      <img src={author.avatar} style={{ width: 32, height: 32, borderRadius: '50%' }} />
      <span style={{ color: '#fff', fontWeight: 600 }}>{author.name}</span>
      {isVideo && <button onClick={onToggleSound}>{isMuted ? 'Unmute' : 'Mute'}</button>}
      <button onClick={onClose} style={{ marginLeft: 'auto' }}>Close</button>
    </div>
  )}
/>

renderFooter

在 story 内容下方加一个页脚:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderFooter={({ author, story, storyIndex }) => (
    <div style={{
      position: 'absolute',
      bottom: 0,
      left: 0,
      right: 0,
      padding: 16,
      background: 'linear-gradient(transparent, rgba(0,0,0,0.6))',
      color: '#fff',
      zIndex: 10,
    }}>
      <span>{author.name} — Story {storyIndex + 1}</span>
    </div>
  )}
/>

renderSlide

完全替换默认的图片 / 视频幻灯片。可以用 ImageStorySlide 和 VideoStorySlide 这些子组件来复用内置的媒体处理:

tsx
import {
  StoriesOverlay,
  ImageStorySlide,
  VideoStorySlide,
} from '@reelkit/react-stories-player';

<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderSlide={({ story, index, groupIndex, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }) => {
    const [w, h] = size;

    if (story.mediaType === 'video') {
      return (
        <div style={{ width: w, height: h, background: '#000' }}>
          <VideoStorySlide
            src={story.src}
            poster={story.poster}
            groupIndex={groupIndex}
            storyIndex={index}
            activeGroupIndex={activeGroupIndex}
            activeStoryIndex={activeStoryIndex}
            onDurationReady={onDurationReady}
            onPlaying={onReady}
            onWaiting={onWaiting}
            onEnded={onEnded}
            onError={onError}
          />
        </div>
      );
    }

    return (
      <div style={{ width: w, height: h, background: '#000' }}>
        <ImageStorySlide src={story.src} onLoad={onReady} onError={onError} />
      </div>
    );
  }}
/>

renderNavigation

替换默认的桌面端箭头按钮:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderNavigation={({ onPrevStory, onNextStory, onPrevGroup, onNextGroup }) => (
    <div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, display: 'flex', justifyContent: 'center', gap: 8, zIndex: 10 }}>
      <button onClick={onPrevGroup}>Prev Group</button>
      <button onClick={onPrevStory}>Prev</button>
      <button onClick={onNextStory}>Next</button>
      <button onClick={onNextGroup}>Next Group</button>
    </div>
  )}
/>

renderProgressBar

用自定义实现替换默认的 canvas 进度条。 progress 信号发出 0 到 1 的值:

tsx
import { Observe } from '@reelkit/react';

<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderProgressBar={({ totalStories, activeIndex, progress }) => (
    <div style={{ display: 'flex', gap: 4, padding: '8px 16px' }}>
      {Array.from({ length: totalStories }, (_, i) => (
        <div key={i} style={{ flex: 1, height: 2, background: 'rgba(255,255,255,0.3)', borderRadius: 1, overflow: 'hidden' }}>
          <Observe signals={[activeIndex, progress]}>
            {() => {
              const fill = i < activeIndex.value ? 1 : i === activeIndex.value ? progress.value : 0;
              return <div style={{ width: `${fill * 100}%`, height: '100%', background: '#fff' }} />;
            }}
          </Observe>
        </div>
      ))}
    </div>
  )}
/>

renderLoading

内容加载期间的自定义加载提示:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderLoading={({ story, storyIndex, groupIndex }) => (
    <div style={{
      position: 'absolute',
      inset: 0,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      color: '#fff',
    }}>
      <span>Loading story {storyIndex + 1}...</span>
    </div>
  )}
/>

renderError

内容加载失败时的自定义错误浮层:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderError={({ story, storyIndex, groupIndex }) => (
    <div style={{
      position: 'absolute',
      inset: 0,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      flexDirection: 'column',
      color: '#fff',
      background: 'rgba(0,0,0,0.8)',
    }}>
      <span style={{ fontSize: 48 }}>!</span>
      <span>Failed to load story</span>
    </div>
  )}
/>

renderGroupPreview

替换桌面端轮播卡片的内容。卡片的位置、缩放和滑动仍由播放器负责;调用 onOpen 打开分组:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  desktopLayout="carousel"
  renderGroupPreview={({ group, story, onOpen }) => (
    <button
      type="button"
      className="my-story-card"
      aria-label={`Open stories by ${group.author.name}`}
      onClick={onOpen}
    >
      {story?.poster && <img src={story.poster} alt="" />}
      <span>{group.author.name}</span>
    </button>
  )}
/>

StoriesApi

Use the apiRef 属性做命令式控制:

tsx
import { useRef } from 'react';
import { StoriesOverlay, type StoriesApi } from '@reelkit/react-stories-player';

function App() {
  const apiRef = useRef<StoriesApi | null>(null);

  return (
    <>
      <button onClick={() => apiRef.current?.nextStory()}>Next Story</button>
      <button onClick={() => apiRef.current?.nextGroup()}>Next Group</button>
      <button onClick={() => apiRef.current?.pause()}>Pause</button>

      <StoriesOverlay
        isOpen={isOpen}
        onClose={handleClose}
        groups={groups}
        apiRef={apiRef}
      />
    </>
  );
}

方法

方法类型说明
nextStory()() => void在当前分组内前进到下一个 story
prevStory()() => void在当前分组内回到上一个 story
nextGroup()() => void切到下一个用户分组
prevGroup()() => void切到上一个用户分组
goToGroup(index)(index: number) => void按索引跳到指定分组
pause()() => void暂停自动播放和进度计时
resume()() => void恢复自动播放和进度计时

双击与点赞

双击时会播放内置的爱心动画,给出即时的视觉反馈。 onDoubleTap 回调会带上分组和 story 索引,你可以据此把点赞存进自己的状态里(调接口、本地存储等等)。播放器内部并不管理点赞状态。

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={() => setIsOpen(false)}
  groups={groups}
  onDoubleTap={(groupIndex, storyIndex) => {
    // Built-in heart animation plays automatically.
    // Handle the like in your own state:
    const story = groups[groupIndex].stories[storyIndex];
    toggleLike(story.id);
  }}
/>

定制爱心动画

通过 --rk-stories-heart-duration 变量调整动画速度(见 主题定制)。要改颜色、尺寸或者干脆隐藏爱心,请直接针对 .rk-stories-heart 类。 HeartAnimation 组件也单独导出,可以独立使用。

css
/* Speed up the pop via the token */
:root {
  --rk-stories-heart-duration: 1s;
}

/* Restyle color and size via the class */
.rk-stories-heart {
  color: #ff3b5c;
  font-size: 80px;
}

/* Or hide the built-in heart entirely */
.rk-stories-heart {
  display: none;
}

内置的爱心动画目前还不能通过 render prop 替换。你可以用 CSS 改样式,或者用 display: none 把它隐藏,然后在 onDoubleTap 回调里做自己的动画。如果你需要一个 renderDoubleTap render prop,欢迎通过 GitHub Issues.

子组件

对外导出的可复用积木,用于在自定义 render props 中组合。每个组件的 props 类型都一并导出——CanvasProgressBarProps、StoryHeaderProps、ImageStorySlideProps、VideoStorySlideProps、StoriesRingProps、StoriesRingListProps 和 HeartAnimationProps——方便包装它们的组件使用:

CanvasProgressBar

基于 canvas 的高性能分段进度条。为每个 story 渲染一段,并用 requestAnimationFrame为当前段做填充动画。story 很多的分组会启用滑动窗口。对只需显示分组停在哪里的进度条,传入 live={false}:它只在信号变化和尺寸改变后重绘,没有动画循环。

tsx
import { CanvasProgressBar } from '@reelkit/react-stories-player';

<CanvasProgressBar
  totalStories={group.stories.length}
  activeIndex={activeIndexSignal}
  progress={progressSignal}
  minSegmentWidth={8}
  gap={2}
  barHeight={2}
  // A bar that is not live draws only when its signals change, with no
  // animation loop: enough for a group that is not playing.
  live={isActiveGroup}
/>

StoryHeader

默认页眉,包含作者头像、名称、认证徽章、相对时间、暂停 / 播放开关、静音开关、加载动画和关闭按钮。未提供 renderHeader 时自动使用。

tsx
import { StoryHeader } from '@reelkit/react-stories-player';

<StoryHeader
  author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg', verified: true }}
  createdAt={new Date(Date.now() - 3600_000)}
  onClose={handleClose}
  isPaused={false}
  onTogglePause={togglePause}
  isMuted={true}
  onToggleSound={toggleSound}
  isVideo={true}
  isLoading={false}
/>

ImageStorySlide

铺满的图片幻灯片,使用 object-fit: cover。通过回调上报加载 / 错误状态以便跟踪生命周期。

tsx
import { ImageStorySlide } from '@reelkit/react-stories-player';

<ImageStorySlide
  src="/photo.jpg"
  aspectRatio={9 / 16}
  onLoad={() => console.log('loaded')}
  onError={() => console.log('failed')}
/>

VideoStorySlide

视频幻灯片,使用共享的 <video> 元素以保证 iOS 上声音连续。它负责自动播放、封面帧、声音同步,并上报时长和播放生命周期事件。

tsx
import { VideoStorySlide } from '@reelkit/react-stories-player';

<VideoStorySlide
  src="/clip.mp4"
  poster="/clip-poster.jpg"
  groupIndex={0}
  storyIndex={2}
  activeGroupIndex={activeGroupSignal}
  activeStoryIndex={activeStorySignal}
  onDurationReady={(ms) => console.log('duration:', ms)}
  onPlaying={() => console.log('playing')}
  onWaiting={() => console.log('buffering')}
  onEnded={() => console.log('ended')}
  onError={() => console.log('error')}
/>

StoriesRing

带 Instagram 风格渐变圆环的圆形头像。分段表示已读 / 未读 story —— 未读是渐变色,已读是灰色。

tsx
import { StoriesRing } from '@reelkit/react-stories-player';

<StoriesRing
  author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg' }}
  totalStories={5}
  viewedCount={2}
  onClick={() => openStories(0)}
/>

StoriesRingList

可横向滚动的一排 StoriesRing 组件,带作者名称。每个分组一个圆环。

tsx
import { StoriesRingList } from '@reelkit/react-stories-player';

// `viewed` is the same createStoriesViewedStateController the player takes; the
// rings follow it by themselves.
<StoriesRingList
  groups={groups}
  viewed={viewed}
  onSelect={(groupIndex) => openStories(groupIndex)}
/>;

HeartAnimation

双击触发的爱心动画浮层。在 800 毫秒内放大并淡出。可通过 CSS 定制(见“双击与点赞”一节)。

tsx
import { HeartAnimation } from '@reelkit/react-stories-player';

<HeartAnimation onComplete={() => console.log('animation done')} />

重新导出

使用方需要的一切都可以从这个包导入,因此永远不必直接从 @reelkit/core 导入。

来源重新导出的内容
@reelkit/reactSoundProvider、useSoundState、useOverlayUrlState、createViewedStateController、twoAxisViewedTracking、createLocalStorageAdapter、createSessionStorageAdapter、createMemoryStorageAdapter、urlIndexTwoAxisKey、urlStableIdTwoAxisKey、base64UrlCodec,以及类型 UrlAdapter、UrlCodec、UrlLocator、UrlKey、UrlStateController、TwoAxisPosition、TwoAxisIdentity、UrlIndexTwoAxisKeyOptions、OverlayUrlStateOptions、ViewedStateController、ViewedStateOptions、StorageAdapter
@reelkit/stories-corecreateStoriesViewedStateController、StoriesViewedStateController、StoriesViewedStateControllerConfig,以及内容类型 StoryItem、AuthorInfo、StoriesGroup、MediaType

SoundProvider 和 useSoundState 承载播放器与其 slide 共享的声音状态。自己播放媒体的自定义 slide 通过 useSoundState 读取静音开关。在播放器内部已经有一个 provider;在其他任何地方渲染的 VideoStorySlide 或自定义 slide,上方都需要一个 SoundProvider,否则 useSoundState 会抛出异常。

类型

StoryItem

typescript
interface StoryItem {
  id: string;
  mediaType: 'image' | 'video';
  src: string;
  poster?: string;
  duration?: number;       // ms, images default to 5000, videos use natural duration
  createdAt?: string | Date;
  aspectRatio?: number;    // width / height
}

AuthorInfo

typescript
interface AuthorInfo {
  id: string;
  name: string;
  avatar: string;
  verified?: boolean;
}

StoriesGroup<T>

typescript
interface StoriesGroup<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  stories: T[];
}

HeaderRenderProps<T>

typescript
interface HeaderRenderProps<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
  isPaused: boolean;
  isMuted: boolean;
  isVideo: boolean;
  groupIndex: number;
  isActive: boolean; // false for a neighbouring group's header
  onToggleSound: () => void;
  onTogglePause: () => void;
  onClose: () => void;
}

FooterRenderProps<T>

typescript
interface FooterRenderProps<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
}

SlideRenderProps<T>

typescript
interface SlideRenderProps<T extends StoryItem = StoryItem> {
  story: T;
  index: number;
  groupIndex: number;
  isActive: boolean;
  size: [number, number];
  activeGroupIndex: Signal<number>;
  activeStoryIndex: Signal<number>;
  onDurationReady: (durationMs: number) => void;
  onReady: () => void;
  onWaiting: () => void;
  onError: () => void;
  onEnded: () => void;
}
typescript
interface NavigationRenderProps {
  onPrevStory: () => void;
  onNextStory: () => void;
  onPrevGroup: () => void;
  onNextGroup: () => void;
}

ProgressBarRenderProps<T>

typescript
interface ProgressBarRenderProps<T extends StoryItem = StoryItem> {
  totalStories: number;
  activeIndex: Signal<number>;
  progress: Signal<number>;
  group: StoriesGroup<T>;
  groupIndex: number;
  isActive: boolean; // false: the signals hold still for a neighbouring group
}

LoadingRenderProps<T>

typescript
interface LoadingRenderProps<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

ErrorRenderProps<T>

typescript
interface ErrorRenderProps<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

GroupPreviewRenderProps<T>

typescript
type DesktopLayout = 'single' | 'carousel';

interface GroupPreviewRenderProps<T extends StoryItem = StoryItem> {
  group: StoriesGroup<T>;
  groupIndex: number;
  story: T | undefined; // the story the group would open on
  offset: number; // negative on the left, positive on the right
  viewedCount: number; // from viewedState, 0 without it
  onOpen: () => void;
}

StoriesApi

typescript
interface StoriesApi {
  nextStory: () => void;
  prevStory: () => void;
  nextGroup: () => void;
  prevGroup: () => void;
  goToGroup: (index: number) => void;
  pause: () => void;
  resume: () => void;
}

自定义 Story 类型

扩展 StoryItem ,加上自定义字段,再把类型参数传给 StoriesOverlay。所有 render props 都会收到你扩展后的类型:

tsx
import {
  StoriesOverlay,
  ImageStorySlide,
  type StoryItem,
  type StoriesGroup,
  type SlideRenderProps,
} from '@reelkit/react-stories-player';

interface PromoStory extends StoryItem {
  title: string;
  subtitle?: string;
  bgGradient?: string;
  ctaText?: string;
}

const groups: StoriesGroup<PromoStory>[] = [
  {
    author: { id: 'brand', name: 'My Brand', avatar: '/brand.png', verified: true },
    stories: [
      {
        id: 'promo-1',
        mediaType: 'image',
        src: '/sale-banner.jpg',
        title: 'Flash Sale',
        subtitle: 'Up to 50% off',
        ctaText: 'Shop Now',
      },
      {
        id: 'promo-2',
        mediaType: 'image',
        src: '',
        title: 'Thank You',
        subtitle: '10K followers!',
        bgGradient: 'linear-gradient(135deg, #a18cd1, #fbc2eb)',
      },
    ],
  },
];

function CustomSlide({ story, size, onReady, onError }: SlideRenderProps<PromoStory>) {
  const [w, h] = size;
  const hasImage = story.src && story.mediaType === 'image';

  return (
    <div style={{ width: w, height: h, background: story.bgGradient ?? '#000', position: 'relative' }}>
      {hasImage && <ImageStorySlide src={story.src} onLoad={onReady} onError={onError} />}
      <div style={{ position: 'relative', zIndex: 1, textAlign: 'center', padding: 32, color: '#fff' }}>
        <h2>{story.title}</h2>
        {story.subtitle && <p>{story.subtitle}</p>}
        {story.ctaText && (
          <button style={{ marginTop: 16, padding: '8px 24px', borderRadius: 20, background: '#fff', color: '#000', border: 'none' }}>
            {story.ctaText}
          </button>
        )}
      </div>
    </div>
  );
}

<StoriesOverlay<PromoStory>
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderSlide={(props) => <CustomSlide {...props} />}
/>

CSS 类名

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

类名组件说明
.rk-stories-overlayOverlay固定的全屏背景层(背景、z-index)
.rk-stories-swipe-wrapperOverlay滑动关闭的包装层(容纳导航按钮 + canvas)
.rk-stories-containerOverlay圆角的 story 画布(定位、溢出)
.rk-stories-ui-layerOverlay界面浮层容器(页眉、进度、导航)。使用 chromePlacement="group" 时位于每个分组的幻灯片内
.rk-stories-ui-layer--hiddenOverlay界面隐藏状态(由 hideUIOnPause 切换)
.rk-stories-errorOverlay错误状态(居中图标 + 文字)
.rk-stories-error-textOverlay错误信息文字
.rk-stories-nav-btn导航桌面端上一张 / 下一张箭头
.rk-stories-progress-barProgressBarcanvas 进度条的定位包装层
.rk-stories-slide-wrapperGroup一个 story 分组(外层幻灯片)
.rk-stories-storyStory单个 story(内层幻灯片根节点)
.rk-stories-headerStoryHeader页眉栏(头像、名称、操作)
.rk-stories-header--hiddenStoryHeader页眉隐藏状态(visible=false)
.rk-stories-header-avatarStoryHeader作者头像图片
.rk-stories-header-nameStoryHeader作者名称文字
.rk-stories-header-verifiedStoryHeader认证徽章容器
.rk-stories-header-timeStoryHeader相对时间文字
.rk-stories-header-actionsStoryHeader右侧操作(关闭、静音、暂停)
.rk-stories-header-btnStoryHeader页眉操作按钮
.rk-stories-header-btn--desktopStoryHeader暂停按钮,仅在宽度大于 768px 时显示
.rk-stories-header-spinnerStoryHeader视频缓冲转圈动画
.rk-stories-imageImageStorySlide图片 story 元素
.rk-stories-videoVideoStorySlide视频 story 容器
.rk-stories-video-elementVideoStorySlide共享的 <video> 元素
.rk-stories-video-posterVideoStorySlide视频封面图(播放时淡出)
.rk-stories-video-poster--visibleVideoStorySlide封面图可见状态(播放前)
.rk-stories-heartHeartAnimation双击时的爱心弹出动画
.rk-stories-ringStoriesRingstory 圆环(带动画渐变边框的头像)
.rk-stories-ring--activeStoriesRing含未读 story 的圆环(会动)
.rk-stories-ring-avatarStoriesRing圆环内部的头像图片
.rk-stories-ring-listStoriesRingList横向圆环列表容器
.rk-stories-ring-list-itemStoriesRingList圆环 + 名称的一列
.rk-stories-ring-list-nameStoriesRingList每个圆环下方的作者名称
.rk-stories-overlay--carouselOverlay正在显示桌面端轮播的浮层
.rk-stories-overlay--slidingOverlay轮播正在滑动;播放器画布被隐藏
.rk-stories-carouselCarousel播放器后方的预览卡片层
.rk-stories-carousel--instantCarousel滑动的第一帧,不带过渡地完成布局
.rk-stories-cardCarousel一张已定位的预览卡片
.rk-stories-card--centerCarousel滑动期间位于中间位置的卡片
.rk-stories-card--hiddenCarousel超出单侧显示的两张之外的卡片
.rk-stories-card-buttonCarousel默认卡片内容(打开分组的按钮)
.rk-stories-card-imageCarousel预览画面(封面或图片)
.rk-stories-card-frameCarousel没有图片或封面的 story 的缩小版 renderSlide 预览;不可交互
.rk-stories-card-scrimCarousel覆盖在画面上的变暗层
.rk-stories-card-infoCarousel头像环、作者名和时间所在的列
.rk-stories-card-nameCarousel作者名
.rk-stories-card-timeCarousel相对时间文字

主题定制

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

变量默认值控制内容
--rk-stories-overlay-bg#000全屏背景颜色
--rk-stories-overlay-z9999浮层 z-index
--rk-stories-swipe-gap16px导航按钮与 story 画布之间的间距
--rk-stories-container-radius12pxstory 画布的圆角(桌面端)
--rk-stories-container-radius-mobile0宽度不超过 768px 时 story 画布的圆角
--rk-stories-ui-z15UI 层(头部、进度条)的 z-index
--rk-stories-ui-transition200mshideUIOnPause 切换时的淡入淡出时长
--rk-stories-top-shade-height120px头部后方顶部渐变遮罩的高度
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)顶部渐变遮罩颜色
--rk-stories-error-z5错误状态的 z-index
--rk-stories-error-bglinear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)错误状态背景渐变
--rk-stories-error-fgrgba(255, 255, 255, 0.5)错误图标和文字颜色
--rk-stories-error-gap12px错误图标与文字之间的间距
--rk-stories-error-text-size13px错误信息字号
--rk-stories-nav-z20桌面端导航按钮的 z-index
--rk-stories-nav-size44px桌面端上一个 / 下一个按钮尺寸
--rk-stories-nav-bgrgba(255, 255, 255, 0.1)桌面端导航按钮背景
--rk-stories-nav-bg-hoverrgba(255, 255, 255, 0.2)桌面端导航按钮悬停背景
--rk-stories-nav-fgrgba(255, 255, 255, 0.7)桌面端导航按钮图标颜色
--rk-stories-nav-fg-hover#fff桌面端导航按钮悬停图标颜色
--rk-stories-nav-transition150ms导航按钮背景和颜色的过渡时长
--rk-stories-video-bg#000<video> 后方的留黑背景
--rk-stories-video-poster-transition200ms视频开始播放时封面淡出的时长
--rk-stories-progress-bar-z10进度条的 z-index
--rk-stories-progress-bar-padding8px 8px 0进度条周围的留白
--rk-stories-header-z5头部的 z-index
--rk-stories-header-top18px头部距 story 顶部的垂直偏移
--rk-stories-header-padding12px 16px头部行的内边距
--rk-stories-header-gap8px头像、名称和时间之间的间距
--rk-stories-header-transition200ms头部隐藏时的淡出时长
--rk-stories-header-avatar-size32px头像宽高
--rk-stories-header-name-fg#fff作者名称颜色
--rk-stories-header-name-size14px作者名称字号
--rk-stories-header-name-weight600作者名称字重
--rk-stories-header-time-fgrgba(255, 255, 255, 0.6)发布时间文字颜色
--rk-stories-header-time-size12px发布时间字号
--rk-stories-header-actions-gap8px头部操作按钮之间的间距
--rk-stories-header-btn-fg#fff头部操作图标颜色(关闭、静音、暂停)
--rk-stories-header-btn-padding4px头部操作按钮的内边距
--rk-stories-header-spinner-size20px缓冲加载圈的尺寸
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)缓冲加载圈的轨道颜色
--rk-stories-header-spinner-fg#fff缓冲加载圈的弧线颜色
--rk-stories-header-spinner-duration0.8s缓冲加载圈转一圈的时长
--rk-stories-heart-z20双击爱心的 z-index
--rk-stories-heart-duration800ms弹出 / 淡出动画时长
--rk-stories-ring-spin-duration4s仍有未看 story 的圆环的旋转时长
--rk-stories-ring-active-scale0.95圆环按下时的缩放比例
--rk-stories-ring-active-gradientconic-gradient(from 180deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888, #f09433)还有未看快拍时圆环显示的内容,可以是任意 CSS 图像或颜色。浅色页面请把它设在 .rk-stories-ring-list 上而不是 :root,这样播放器的轮播会保留自己的颜色
--rk-stories-ring-viewed-colorrgba(255, 255, 255, 0.25)全部看完的圆环显示的内容,默认值适合深色页面。浅色页面请把它设在 .rk-stories-ring-list 上而不是 :root
--rk-stories-ring-gradientnone内部使用:圆环每次渲染都会写入它,并指向上面两个令牌,除非给圆环传了 gradientColors 或 viewedColor
--rk-stories-ring-list-gap12px列表中圆环之间的间距
--rk-stories-ring-list-padding12px圆环列表的内边距
--rk-stories-ring-list-item-gap4px圆环与其名称之间的间距
--rk-stories-ring-list-name-size12px每个圆环下方作者名称的字号
--rk-stories-card-bg#262626轮播卡片背景,没有预览画面时可见
--rk-stories-card-radius8px轮播卡片圆角
--rk-stories-card-scrimrgba(0, 0, 0, 0.45)侧边卡片上的变暗层
--rk-stories-card-fg#fff卡片文字颜色
--rk-stories-card-name-size14px卡片作者名字号
--rk-stories-card-time-fgrgba(255, 255, 255, 0.7)卡片相对时间文字颜色
--rk-stories-card-time-size13px卡片相对时间字号
--rk-stories-card-gap6px卡片上头像环、名字和时间之间的间距
--rk-stories-card-transition300ms轮播滑动时长

把下面这段放进一个在 @reelkit/react-stories-player/styles.css 之后加载的样式表。

css
/* Brand the stories overlay */
:root {
  --rk-stories-overlay-bg: #0f172a;
  --rk-stories-container-radius: 24px;
  --rk-stories-nav-bg: rgba(99, 102, 241, 0.25);
  --rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.55);
  --rk-stories-top-shade-bg: linear-gradient(
    to bottom,
    rgba(99, 102, 241, 0.5) 0%,
    transparent 100%
  );
  --rk-stories-header-name-fg: #fef3c7;
  --rk-stories-ring-spin-duration: 2s;
}

/* Rings on a light page. Set on the ring list, not :root, so the rings of the
   player's own carousel keep the colours made for its dark backdrop. */
.rk-stories-ring-list {
  --rk-stories-ring-viewed-color: #d4d4d8;
}

无障碍

浮层根节点是一个模态对话框(role="dialog", aria-modal="true")。设置 ariaLabel 可以改变屏幕阅读器的播报内容,默认是 “Stories player”。

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

在桌面端轮播中,每张默认卡片都是一个按钮,标签为 “Open stories by” 加上作者名。卡片在 Tab 顺序中排在播放器控件之后,滑动进行时会暂时移出 Tab 顺序。

服务端渲染

浮层关闭时不渲染任何内容,只有在打开后才通过 portal 挂载到 document.body,而打开只会发生在客户端。StoriesUrlOverlay 在挂载后的 effect 中读取地址栏,因此服务端渲染永远不会打开它。StoriesRingList 在服务端把每个圆环都渲染为未观看:已观看存储只在挂载后才读取,所以服务端标记与客户端首次渲染保持一致。更完整的说明见 SSR 指南。

键盘快捷键

Key作用
ArrowLeft上一个 story。在分组的第一个 story 上时,切到上一个分组,停在离开时所在的 story(否则是它的续播 story);在第一个分组上无动作
ArrowRight下一个 story。越过分组的最后一个 story 时,切到下一个分组;越过最后一个分组时,播放器关闭
Escape关闭播放器