Stories Player
一个面向 React 的 Instagram 风格 Stories 播放浮层,基于 @reelkit/react-stories-player.
特性
安装
npm i @reelkit/react-stories-player @reelkit/react lucide-react别忘了引入样式:
import '@reelkit/react-stories-player/styles.css';图标
默认页眉使用 lucide-react 作为图标。如果你想换一套图标库,可以用 renderHeader 和 renderNavigation 提供自己的实现。
快速上手
StoriesOverlay 组件渲染一个全屏 Stories Player。搭配 StoriesRingList 作为 Instagram 风格的入口。传入一组 StoriesGroup 对象,并用 isOpen.
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>
);
}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.
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 过期,下一次导航就会把参数丢掉:
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 那一半仍然是普通的局部索引。
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 仍是解析出的分组内的局部索引。
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 中同样可用。
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 控制器,已看完分组的卡片就会显示灰色的环。卡片会自行跟随它,因此故事被标记为已看时只有卡片重绘,播放器不会重新渲染。
// 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 那样随分组一起转动。
// 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>
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
isOpen | boolean | 必填 | 控制浮层的显示。为 true 时会锁住 body 滚动。 |
groups | StoriesGroup<T>[] | 必填 | 要展示的 story 分组数组 |
onClose | () => void | 必填 | 关闭浮层的回调 |
ariaLabel | string | 'Stories player' | 对话框区域的无障碍标签;浮层打开时由屏幕阅读器播报 |
initialGroupIndex | number | 0 | 初始可见分组的索引(从 0 开始) |
initialStoryIndex | number | resumeStoryIndex(initialGroupIndex),否则为 0 | 分组内初始可见 story 的索引(从 0 开始)。显式指定时优先于任何记忆;省略则初始分组像其他分组一样恢复。 |
resumeStoryIndex | (groupIndex: number) => number | — | 分组第一次被打开时所在的 story,让观看者从上次离开处继续。仅对本次打开中尚未访问的分组生效。 |
groupTransition | TransitionTransformFn | cubeTransition | 外层(分组)滑动器的过渡效果 |
defaultImageDuration | number | 5000 | 图片类 story 的默认自动播放时长(毫秒) |
tapZoneSplit | number | 0.3 | 点击区域的分割比例(0–1)。左侧触发上一个,右侧触发下一个。 |
hideUIOnPause | boolean | true | 长按暂停期间隐藏进度条和头部 |
enableKeyboard | boolean | true | 启用键盘导航(左右方向键、Escape) |
innerTransitionDuration | number | 200 | 内层(story)过渡动画的时长(毫秒) |
minSegmentWidth | number | 8 | 进度条分段的最小宽度(像素) |
apiRef | MutableRefObject<StoriesApi | null> | - | 用于访问命令式 StoriesApi 的 ref |
desktopLayout | 'single' | 'carousel' | 'single' | 桌面端布局。'carousel' 在当前故事两侧以预览卡片显示相邻分组,并在分组之间滑动。手机上始终只显示故事本身。类型以 DesktopLayout 导出。 |
chromePlacement | 'overlay' | 'group' | 'overlay' | 进度条和页眉放在哪里。'overlay' 在播放器上方绘制一份;'group' 在每个分组的幻灯片里各绘制一份,二者随分组一起转动。类型以 ChromePlacement 导出。 |
viewed | StoriesViewedStateController | - | 观看者已看过的内容,来自 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 —— 它们改由控制器提供。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
controller | UrlStateController<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:
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
替换默认页眉(作者信息、暂停 / 静音按钮、关闭按钮):
<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 内容下方加一个页脚:
<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 这些子组件来复用内置的媒体处理:
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
替换默认的桌面端箭头按钮:
<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 的值:
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
内容加载期间的自定义加载提示:
<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
内容加载失败时的自定义错误浮层:
<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 打开分组:
<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 属性做命令式控制:
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 索引,你可以据此把点赞存进自己的状态里(调接口、本地存储等等)。播放器内部并不管理点赞状态。
<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 组件也单独导出,可以独立使用。
/* 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}:它只在信号变化和尺寸改变后重绘,没有动画循环。
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 时自动使用。
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。通过回调上报加载 / 错误状态以便跟踪生命周期。
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 上声音连续。它负责自动播放、封面帧、声音同步,并上报时长和播放生命周期事件。
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 —— 未读是渐变色,已读是灰色。
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 组件,带作者名称。每个分组一个圆环。
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 定制(见“双击与点赞”一节)。
import { HeartAnimation } from '@reelkit/react-stories-player';
<HeartAnimation onComplete={() => console.log('animation done')} />重新导出
使用方需要的一切都可以从这个包导入,因此永远不必直接从 @reelkit/core 导入。
| 来源 | 重新导出的内容 |
|---|---|
@reelkit/react | SoundProvider、useSoundState、useOverlayUrlState、createViewedStateController、twoAxisViewedTracking、createLocalStorageAdapter、createSessionStorageAdapter、createMemoryStorageAdapter、urlIndexTwoAxisKey、urlStableIdTwoAxisKey、base64UrlCodec,以及类型 UrlAdapter、UrlCodec、UrlLocator、UrlKey、UrlStateController、TwoAxisPosition、TwoAxisIdentity、UrlIndexTwoAxisKeyOptions、OverlayUrlStateOptions、ViewedStateController、ViewedStateOptions、StorageAdapter |
@reelkit/stories-core | createStoriesViewedStateController、StoriesViewedStateController、StoriesViewedStateControllerConfig,以及内容类型 StoryItem、AuthorInfo、StoriesGroup、MediaType |
SoundProvider 和 useSoundState 承载播放器与其 slide 共享的声音状态。自己播放媒体的自定义 slide 通过 useSoundState 读取静音开关。在播放器内部已经有一个 provider;在其他任何地方渲染的 VideoStorySlide 或自定义 slide,上方都需要一个 SoundProvider,否则 useSoundState 会抛出异常。
类型
StoryItem
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
interface AuthorInfo {
id: string;
name: string;
avatar: string;
verified?: boolean;
}StoriesGroup<T>
interface StoriesGroup<T extends StoryItem = StoryItem> {
author: AuthorInfo;
stories: T[];
}HeaderRenderProps<T>
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>
interface FooterRenderProps<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
}SlideRenderProps<T>
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;
}NavigationRenderProps
interface NavigationRenderProps {
onPrevStory: () => void;
onNextStory: () => void;
onPrevGroup: () => void;
onNextGroup: () => void;
}ProgressBarRenderProps<T>
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>
interface LoadingRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}ErrorRenderProps<T>
interface ErrorRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}GroupPreviewRenderProps<T>
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
interface StoriesApi {
nextStory: () => void;
prevStory: () => void;
nextGroup: () => void;
prevGroup: () => void;
goToGroup: (index: number) => void;
pause: () => void;
resume: () => void;
}自定义 Story 类型
扩展 StoryItem ,加上自定义字段,再把类型参数传 给 StoriesOverlay。所有 render props 都会收到你扩展后的类型:
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-overlay | Overlay | 固定的全屏背景层(背景、z-index) |
.rk-stories-swipe-wrapper | Overlay | 滑动关闭的包装层(容纳导航按钮 + canvas) |
.rk-stories-container | Overlay | 圆角的 story 画布(定位、溢出) |
.rk-stories-ui-layer | Overlay | 界面浮层容器(页眉、进度、导航)。使用 chromePlacement="group" 时位于每个分组的幻灯片内 |
.rk-stories-ui-layer--hidden | Overlay | 界面隐藏状态(由 hideUIOnPause 切换) |
.rk-stories-error | Overlay | 错误状态(居中图标 + 文字) |
.rk-stories-error-text | Overlay | 错误信息文字 |
.rk-stories-nav-btn | 导航 | 桌面端上一张 / 下一张箭头 |
.rk-stories-progress-bar | ProgressBar | canvas 进度条的定位包装层 |
.rk-stories-slide-wrapper | Group | 一个 story 分组(外层幻灯片) |
.rk-stories-story | Story | 单个 story(内层幻灯片根节点) |
.rk-stories-header | StoryHeader | 页眉栏(头像、名称、操作) |
.rk-stories-header--hidden | StoryHeader | 页眉隐藏状态(visible=false) |
.rk-stories-header-avatar | StoryHeader | 作者头像图片 |
.rk-stories-header-name | StoryHeader | 作者名称文字 |
.rk-stories-header-verified | StoryHeader | 认证徽章容器 |
.rk-stories-header-time | StoryHeader | 相对时间文字 |
.rk-stories-header-actions | StoryHeader | 右侧操作(关闭、静音、暂停) |
.rk-stories-header-btn | StoryHeader | 页眉操作按钮 |
.rk-stories-header-btn--desktop | StoryHeader | 暂停按钮,仅在宽度大于 768px 时显示 |
.rk-stories-header-spinner | StoryHeader | 视频缓冲转圈动画 |
.rk-stories-image | ImageStorySlide | 图片 story 元素 |
.rk-stories-video | VideoStorySlide | 视频 story 容器 |
.rk-stories-video-element | VideoStorySlide | 共享的 <video> 元素 |
.rk-stories-video-poster | VideoStorySlide | 视频封面图(播放时淡出) |
.rk-stories-video-poster--visible | VideoStorySlide | 封面图可见状态(播放前) |
.rk-stories-heart | HeartAnimation | 双击时的爱心弹出动画 |
.rk-stories-ring | StoriesRing | story 圆环(带动画渐变边框的头像) |
.rk-stories-ring--active | StoriesRing | 含未读 story 的圆环(会动) |
.rk-stories-ring-avatar | StoriesRing | 圆环内部的头像图片 |
.rk-stories-ring-list | StoriesRingList | 横向圆环列表容器 |
.rk-stories-ring-list-item | StoriesRingList | 圆环 + 名称的一列 |
.rk-stories-ring-list-name | StoriesRingList | 每个圆环下方的作者名称 |
.rk-stories-overlay--carousel | Overlay | 正在显示桌面端轮播的浮层 |
.rk-stories-overlay--sliding | Overlay | 轮播正在滑动;播放器画布被隐藏 |
.rk-stories-carousel | Carousel | 播放器后方的预览卡片层 |
.rk-stories-carousel--instant | Carousel | 滑动的第一帧,不带过渡地完成布局 |
.rk-stories-card | Carousel | 一张已定位的预览卡片 |
.rk-stories-card--center | Carousel | 滑动期间位于中间位置的卡片 |
.rk-stories-card--hidden | Carousel | 超出单侧显示的两张之外的卡片 |
.rk-stories-card-button | Carousel | 默认卡片内容(打开分组的按钮) |
.rk-stories-card-image | Carousel | 预览画面(封面或图片) |
.rk-stories-card-frame | Carousel | 没有图片或封面的 story 的缩小版 renderSlide 预览;不可交互 |
.rk-stories-card-scrim | Carousel | 覆盖在画面上的变暗层 |
.rk-stories-card-info | Carousel | 头像环、作者名和时间所在的列 |
.rk-stories-card-name | Carousel | 作者名 |
.rk-stories-card-time | Carousel | 相对时间文字 |
主题定制
每一个颜色、尺寸、z-index 和过渡都放在 CSS 自定义属性里。在 :root (或浮层的任意祖先元素)上覆盖其中一个或多个 ,即可在不改组件源码的情况下换主题。
| 变量 | 默认值 | 控制内容 |
|---|---|---|
--rk-stories-overlay-bg | #000 | 全屏背景颜色 |
--rk-stories-overlay-z | 9999 | 浮层 z-index |
--rk-stories-swipe-gap | 16px | 导航按钮与 story 画布之间的间距 |
--rk-stories-container-radius | 12px | story 画布的圆角(桌面端) |
--rk-stories-container-radius-mobile | 0 | 宽度不超过 768px 时 story 画布的圆角 |
--rk-stories-ui-z | 15 | UI 层(头部、进度条)的 z-index |
--rk-stories-ui-transition | 200ms | hideUIOnPause 切换时的淡入淡出时长 |
--rk-stories-top-shade-height | 120px | 头部后方顶部渐变遮罩的高度 |
--rk-stories-top-shade-bg | linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%) | 顶部渐变遮罩颜色 |
--rk-stories-error-z | 5 | 错误状态的 z-index |
--rk-stories-error-bg | linear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%) | 错误状态背景渐变 |
--rk-stories-error-fg | rgba(255, 255, 255, 0.5) | 错误图标和文字颜色 |
--rk-stories-error-gap | 12px | 错误图标与文字之间的间距 |
--rk-stories-error-text-size | 13px | 错误信息字号 |
--rk-stories-nav-z | 20 | 桌面端导航按钮的 z-index |
--rk-stories-nav-size | 44px | 桌面端上一个 / 下一个按钮尺寸 |
--rk-stories-nav-bg | rgba(255, 255, 255, 0.1) | 桌面端导航按钮背景 |
--rk-stories-nav-bg-hover | rgba(255, 255, 255, 0.2) | 桌面端导航按钮悬停背景 |
--rk-stories-nav-fg | rgba(255, 255, 255, 0.7) | 桌面端导航按钮图标颜色 |
--rk-stories-nav-fg-hover | #fff | 桌面端导航按钮悬停图标颜色 |
--rk-stories-nav-transition | 150ms | 导航按钮背景和颜色的过渡时长 |
--rk-stories-video-bg | #000 | <video> 后方的留黑背景 |
--rk-stories-video-poster-transition | 200ms | 视频开始播放时封面淡出的时长 |
--rk-stories-progress-bar-z | 10 | 进度条的 z-index |
--rk-stories-progress-bar-padding | 8px 8px 0 | 进度条周围的留白 |
--rk-stories-header-z | 5 | 头部的 z-index |
--rk-stories-header-top | 18px | 头部距 story 顶部的垂直偏移 |
--rk-stories-header-padding | 12px 16px | 头部行的内边距 |
--rk-stories-header-gap | 8px | 头像、名称和时间之间的间距 |
--rk-stories-header-transition | 200ms | 头部隐藏时的淡出时长 |
--rk-stories-header-avatar-size | 32px | 头像宽高 |
--rk-stories-header-name-fg | #fff | 作者名称颜色 |
--rk-stories-header-name-size | 14px | 作者名称字号 |
--rk-stories-header-name-weight | 600 | 作者名称字重 |
--rk-stories-header-time-fg | rgba(255, 255, 255, 0.6) | 发布时间文字颜色 |
--rk-stories-header-time-size | 12px | 发布时间字号 |
--rk-stories-header-actions-gap | 8px | 头部操作按钮之间的间距 |
--rk-stories-header-btn-fg | #fff | 头部操作图标颜色(关闭、静音、暂停) |
--rk-stories-header-btn-padding | 4px | 头部操作按钮的内边距 |
--rk-stories-header-spinner-size | 20px | 缓冲加载圈的尺寸 |
--rk-stories-header-spinner-track | rgba(255, 255, 255, 0.3) | 缓冲加载圈的轨道颜色 |
--rk-stories-header-spinner-fg | #fff | 缓冲加载圈的弧线颜色 |
--rk-stories-header-spinner-duration | 0.8s | 缓冲加载圈转一圈的时长 |
--rk-stories-heart-z | 20 | 双击爱心的 z-index |
--rk-stories-heart-duration | 800ms | 弹出 / 淡出动画时长 |
--rk-stories-ring-spin-duration | 4s | 仍有未看 story 的圆环的旋转时长 |
--rk-stories-ring-active-scale | 0.95 | 圆环按下时的缩放比例 |
--rk-stories-ring-active-gradient | conic-gradient(from 180deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888, #f09433) | 还有未看快拍时圆环显示的内容,可以是任意 CSS 图像或颜色。浅色页面请把它设在 .rk-stories-ring-list 上而不是 :root,这样播放器的轮播会保留自己的颜色 |
--rk-stories-ring-viewed-color | rgba(255, 255, 255, 0.25) | 全部看完的圆环显示的内容,默认值适合深色页面。浅色页面请把它设在 .rk-stories-ring-list 上而不是 :root |
--rk-stories-ring-gradient | none | 内部使用:圆环每次渲染都会写入它,并指向上面两个令牌,除非给圆环传了 gradientColors 或 viewedColor |
--rk-stories-ring-list-gap | 12px | 列表中圆环之间的间距 |
--rk-stories-ring-list-padding | 12px | 圆环列表的内边距 |
--rk-stories-ring-list-item-gap | 4px | 圆环与其名称之间的间距 |
--rk-stories-ring-list-name-size | 12px | 每个圆环下方作者名称的字号 |
--rk-stories-card-bg | #262626 | 轮播卡片背景,没有预览画面时可见 |
--rk-stories-card-radius | 8px | 轮播卡片圆角 |
--rk-stories-card-scrim | rgba(0, 0, 0, 0.45) | 侧边卡片上的变暗层 |
--rk-stories-card-fg | #fff | 卡片文字颜色 |
--rk-stories-card-name-size | 14px | 卡片作者名字号 |
--rk-stories-card-time-fg | rgba(255, 255, 255, 0.7) | 卡片相对时间文字颜色 |
--rk-stories-card-time-size | 13px | 卡片相对时间字号 |
--rk-stories-card-gap | 6px | 卡片上头像环、名字和时间之间的间距 |
--rk-stories-card-transition | 300ms | 轮播滑动时长 |
把下面这段放进一个在 @reelkit/react-stories-player/styles.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 | 关闭播放器 |