Stories Core
驱动 @reelkit/react-stories-player的引擎。纯 TypeScript,不依赖任何框架。可以用它为 Angular、Vue 或原生 JS 构建 Stories Player。
安装
npm i @reelkit/stories-coreStories 控制器
createStoriesController(config, events?) 返回一个 StoriesController,负责在分组和 story 之间导航。它跟踪暂停 / 恢复状态,记住每个分组最后看到的 story,并在每次切换时触发回调。
配置(StoriesControllerConfig)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
groupCount | number | 必填 | story 分组总数 |
storyCounts | number[] | 必填 | 每个分组内的 story 数量 |
initialGroupIndex | number | 0 | 初始分组索引 |
initialStoryIndex | number | resumeStoryIndex(initialGroupIndex),否则为 0 | 分组内的初始 story 索引。显式指定时优先于任何记忆;省略则初始分组像其他分组一样恢复。 |
defaultImageDuration | number | 5000 | 图片类 story 的默认自动播放时长(毫秒) |
resumeStoryIndex | (groupIndex: number) => number | undefined | 尚未访问的分组打开时所在的 story;限制在该分组实际拥有的 story 范围内 |
事件(StoriesControllerEvents)
| 事件 | 类型 | 说明 |
|---|---|---|
onStoryChange | (groupIndex, storyIndex) => void | 当前 story 变化时触发 |
onGroupChange | (groupIndex) => void | 当前分组变化时触发 |
onStoryViewed | (groupIndex, storyIndex) => void | 某个 story 变为可见时触发 |
onStoryComplete | (groupIndex, storyIndex) => void | 某个 story 的计时器走完时触发(在切换之前) |
onComplete | () => void | 最后一个分组的最后一个 story 播完时触发 |
onClose | () => void | 浮层应当关闭时触发 |
状态(响应式信号)
| Signal | 类型 | 说明 |
|---|---|---|
state.activeGroupIndex | Signal<number> | 当前分组索引 |
state.activeStoryIndex | Signal<number> | 分组内当前 story 的索引 |
state.isPaused | Signal<boolean> | 自动播放是否已暂停 |
方法
| 方法 | 类型 | 说明 |
|---|---|---|
nextStory() | () => void | 在分组内前进;越过边界时进入下一个分组 |
prevStory() | () => void | 在分组内后退;越过边界时回到上一个分组 |
nextGroup() | () => void | 切到下一个分组,并从上次看到的 story 继续 |
prevGroup() | () => void | 切到上一个分组,并从上次看到的 story 继续 |
goToGroup(index) | (number) => void | 按索引跳到指定分组 |
pause() | () => void | 暂停自动播放 |
resume() | () => void | 恢复自动播放 |
onStoryTimerComplete() | () => void | 计时结束时调用;先触发 onStoryComplete 再前进 |
getLastStoryIndex(groupIndex) | (number) => number | 分组打开的位置:本次会话中离开时所在的 story,否则由 resumeStoryIndex 指定 |
reportInitialView() | () => void | 把播放器打开时所在的 story 报告为已观看,仅一次。挂载后调用,不要在渲染期间调用。 |
updateConfig({ groupCount, storyCounts }) | (config) => void | 播放器打开期间 feed 发生变化时,替换分组数量和各组的 story 数量,让之后加载的分组也能到达。不触发任何事件,保留每个分组离开时的位置;feed 变短时会把位置拉回有效范围内。 |
示例
import { createStoriesController } from '@reelkit/stories-core';
import { createTimerController, reaction } from '@reelkit/core';
const groups = [
{ stories: ['s1', 's2', 's3'] },
{ stories: ['s4', 's5'] },
];
const controller = createStoriesController(
{
groupCount: groups.length,
storyCounts: groups.map((g) => g.stories.length),
defaultImageDuration: 5000,
},
{
onStoryChange(groupIndex, storyIndex) {
console.log('Story changed:', groupIndex, storyIndex);
},
onComplete() {
console.log('All stories viewed');
},
onClose() {
console.log('Overlay closed');
},
},
);
// Wire up a timer for auto-advance
const timer = createTimerController({
duration: 5000,
onComplete: () => controller.onStoryTimerComplete(),
});
// React to story changes and restart the timer
const dispose = reaction(
() => [
controller.state.activeGroupIndex,
controller.state.activeStoryIndex,
],
() => timer.start(),
);
// Start playback
timer.start();
// Navigation
controller.nextStory();
controller.pause();
controller.resume();
// Cleanup
dispose();
timer.dispose();计时控制器
驱动每个 story 进度的计时器位于 @reelkit/core,名为 createTimerController,与 lightbox 幻灯片共用。从 @reelkit/core 或你的绑定(@reelkit/react、@reelkit/vue、@reelkit/angular)导入它;本包不再导出它。它的配置、状态和方法见核心 API 中的自动播放,把它接到 onStoryTimerComplete 的方式见上方的 Stories 控制器示例。
Canvas 进度渲染器
createCanvasProgressRenderer(config?) 在 canvas 上绘制分段进度条。它会针对 Retina 屏缩放,通过 @reelkit/core 的 observeElementSize 跟随容器宽度,并在分段放不下时启用滑动窗口。
配置(CanvasProgressRendererConfig)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
gap | number | 2 | 分段之间的间距(像素) |
barHeight | number | 2 | 进度条高度(像素) |
minSegmentWidth | number | 8 | 触发滑动窗口之前的最小分段宽度 |
bgColor | string | 'rgba(255,255,255,0.3)' | 未填充分段的背景色 |
fillColor | string | '#ffffff' | 已完成 / 当前分段的填充色 |
onResize | () => void | - | 容器尺寸变化、canvas 重新测量之后调用。调整 canvas 尺寸会清空它,所以请在这里重绘。 |
方法
createCanvasProgressRenderer 返回一个 CanvasProgressRenderer,为保存渲染器的组件字段或 ref 标注类型时用的就是这个对象:
| 成员 | 类型 | 说明 |
|---|---|---|
attach(canvas) | (HTMLCanvasElement) => void | 挂到 canvas 元素上;并开始监听父级的尺寸 |
draw(totalStories, activeIndex, progress) | (number, number, number) => void | 按给定状态绘制进度条 |
width | number (readonly) | 当前测得的宽度(CSS 像素) |
dispose() | () => void | 停止监听父级的尺寸并清理内部状态 |
示例
import { createCanvasProgressRenderer } from '@reelkit/stories-core';
const renderer = createCanvasProgressRenderer({
gap: 2,
barHeight: 2,
fillColor: '#ffffff',
bgColor: 'rgba(255, 255, 255, 0.3)',
});
// Attach to a canvas element
const canvas = document.querySelector('canvas')!;
renderer.attach(canvas);
// Draw on each animation frame
let frameId: number;
function loop() {
const totalStories = 5;
const activeIndex = 2;
const progress = timer.progress.value; // 0-1
renderer.draw(totalStories, activeIndex, progress);
frameId = requestAnimationFrame(loop);
}
frameId = requestAnimationFrame(loop);
// Cleanup
cancelAnimationFrame(frameId);
renderer.dispose();已观看控制器
createStoriesViewedStateController(config) 用一次调用提供 feed 记住已观看内容所需的一切:圆环计数、每个分组从哪里续播,以及观看记录,并写入能经受刷新的存储。它不引入任何框架,所以 React、Vue 和 Angular 的用法完全一样:在 feed 所在的地方创建一次,然后交给圆环列表和播放器,它们会自己完成挂接并跟随它。它基于核心的 createViewedStateController 和稳定 id 的双轴键构建;当它不适用时,直接使用那些部件。记录记的是到达的最远一条 story,而不是观看次数,因此分组的位置在信息流重排后仍然保留,而从分组中间删掉一条 story 会让计数变短并重新点亮圆环。
配置(StoriesViewedStateControllerConfig)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
storageKey | string | 必填 | 条目写入时使用的存储键 |
groups | () => StoriesGroup<T>[] | 必填 | 读取当前的分组。它是一个 getter,每次都会被调用,所以 feed 加载更多分组时只需把它们返回即可。请让它保持最新:一直返回第一个数组的 getter 会把之后加载的所有分组都算错。 |
key | UrlKey<Id, TwoAxisPosition> | 稳定 id | 位置在存储中的写法。传入 URL 控制器使用的 key,存储的条目读起来就和分享链接一样。 |
storage | StorageAdapter | localStorage | 条目保存的位置。自定义适配器也是接入你自己的已观看存储的方式。 |
ttlMs | number | 永不过期 | 分组的记录在最后一次记录之后保留 多久。每个分组按自己的时钟过期。 |
maxTracks | number | 全部保留 | 保留多少个分组。超出后,在下一次写入时丢弃记录时间最早的分组。 |
storageKey、key 和 storage 只在控制器创建时读取一次。要切换存储,请另外创建一个控制器。
StoriesViewedStateController
| 成员 | 类型 | 说明 |
|---|---|---|
viewedState | Subscribable<Map<string, number>> | 按作者 id 统计的已看 story 数,以 signal 形式提供。attach() 之前为空。 |
controller | ViewedStateController<TwoAxisPosition> | 底层的存储,用于这里没有覆盖的需求 |
resumeStoryIndex(groupIndex) | (number) => number | 第一个未看的 story;分组已看完时为 0 |
markViewed(groupIndex, storyIndex) | (number, number) => void | 把某条 story 记为已看。播放器会为每条显示过的 story 调用它。 |
forget() | () => void | 清除所有已观看记录,包括存储中的 |
attach() | () => Dispose | 读取存储并跟随其他标签页。带引用计数,因此圆环列表和播放器可以以任意顺序挂接和卸载。播放器组件会自己调用它。 |
示例
import { createStoriesViewedStateController } from '@reelkit/stories-core';
// Once, where the feed lives. The getter is read every time, so it has to
// return the groups as they are now.
const viewed = createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => groups,
});
// The player components do this themselves when given `viewed`.
const detach = viewed.attach();
viewed.viewedState.value; // Map { 'user_42' => 2 }, and a signal to follow
viewed.resumeStoryIndex(0); // 2 — the first story not yet seen
viewed.markViewed(0, 2); // furthest point wins; a rewatch never rewinds
viewed.forget(); // clears it all
detach();工具函数
用于点击区域检测和进度条计算的纯函数。
| 函数 | 类型 | 说明 |
|---|---|---|
getTapAction(tapX, containerWidth, splitRatio?) | (number, number, number?) => 'prev' | 'next' | 根据点击位置判断触发 'prev' 还是 'next'。splitRatio 默认为 0.3。 |
getSegments(totalStories, activeIndex, progress) | (number, number, number) => SegmentState[] | 计算进度条中每个分段的状态和填充比例 |
getVisibleWindow(totalStories, activeIndex, progress, containerWidth, minSegmentWidth?, gap?) | (number, number, number, number, number?, number?) => VisibleWindow | 当分段总数超出容器容量时,计算可见的滑动窗口 |
布局辅助函数
每个绑定的播放器共用的纯函数,因此 React、Vue 和 Angular 的尺寸与布局完全一致。调用它们的是绑定本身;只有当你要自己写一个播放器时,应用才需要直接用到。
尺寸
| 函数 | 类型 | 说明 |
|---|---|---|
getStoriesSize() | () => [number, number] | 故事画布的尺寸。手机占满屏幕;桌面按 9:16 占满窗口高度并留出上下各 16px 的边距,只有当画布和两侧箭头横向放不下时才会变窄。没有 window(服务端渲染)时返回 [0, 0]。 |
parseDurationMs(value) | (string) => number | 计算后的 transition-duration 列表中最长的时间,单位毫秒。不是时间的值按 0 计。 |
getStoriesSize 使用的手机断点是 isMobileWidth,由 @reelkit/core 和每个绑定导出:768px 及以下算手机。参见尺寸检测。
桌面轮播的几何计算
偏移量相对当前分组:左侧为负,右侧为正,0 表示当前故事本身。
| 函数 | 类型 | 说明 |
|---|---|---|
getCardSize(activeSize) | ([number, number]) => [number, number] | 侧边卡片的尺寸:当前故事高度的 0.4,保持同样的 9:16 比例。 |
getCarouselSlot(offset, activeSize) | (number, [number, number]) => CarouselSlot | 卡片相对窗口中心的位置:{ x, width, height }。每侧第一张卡片让开箭头,之后每张再加一个卡片宽度和一个间距。 |
getCardOffsets(activeGroupIndex, groupCount) | (number, number) => number[] | 静止状态下围绕当前分组绘制的卡片偏移量,每侧最多两张,不含当前分组。 |
isCardShown(offset) | (number) => boolean | 该距离上的卡片是绘制出来还是淡出。 |
getSlotOffset(offset) | (number) => number | 卡片移动到的位置,最多夹到显示范围外一格,好让卡片从最后一张可见卡片旁边滑入。 |
getSlideGroupIndexes(from, to, groupCount) | (number, number, number) => number[] | 两个分组之间滑动时要绘制的分组:两端各自周围的卡片,按分组顺序排列。远距离跳转经过的分组不会绘制。 |
呈现
| 函数 | 类型 | 说明 |
|---|---|---|
getRingPresentation(options) | (RingPresentationOptions) => RingPresentation | 根据 totalStories、viewedCount 和 size 得出故事圆环的类名、内联样式和头像尺寸。还有未看内容时是旋转的锥形渐变,全部看完是纯色 viewedColor,空分组则没有圆环。尺寸带 px 单位,因此这个样式对象在任何框架里都能用。颜色来自 --rk-stories-ring-active-gradient 和 --rk-stories-ring-viewed-color 令牌,页面可以用 CSS 修改;gradientColors 和 viewedColor 只为单个圆环设定颜色。 |
kStoriesRingSize, kStoriesRingListRingSize, kStoriesCardRingSize | number | 各绑定默认绘制的圆环直径(像素):单个圆环 68,圆环列表中 64,桌面轮播卡片上 52。把其中一个作为 size 传入即可与播放器一致。 |
getPreviewSource(story) | (StoryItem | undefined) => string | undefined | 轮播卡片预览的图片:故事的 poster;若是图片就用图片本身;若是没有海报的视频则没有 。没有图片的卡片会退回到为无可预览内容的故事绘制的朴素卡片。 |
formatTimeAgo(date) | (string | Date) => string | 头部和轮播卡片显示的简短相对时间:now、5m、3h、2d、2w。 |
与它们一同导出的还有 CarouselSlot、RingPresentation 和 RingPresentationOptions 类型。
类型
全部类型定义均导出自 @reelkit/stories-core.
type MediaType = 'image' | 'video';
interface StoryItem {
id: string;
mediaType: MediaType;
src: string;
poster?: string;
duration?: number;
createdAt?: string | Date;
aspectRatio?: number;
}
interface AuthorInfo {
id: string;
name: string;
avatar: string;
verified?: boolean;
}
interface StoriesGroup<T extends StoryItem = StoryItem> {
author: AuthorInfo;
stories: T[];
}
type SegmentStatus = 'completed' | 'active' | 'upcoming';
interface SegmentState {
status: SegmentStatus;
fillPercentage: number; // 0-100
}
interface VisibleWindow {
startIndex: number;
endIndex: number;
segments: SegmentState[];
}
type TapAction = 'prev' | 'next';
interface CarouselSlot {
x: number;
width: number;
height: number;
}
interface RingPresentationOptions {
totalStories: number;
viewedCount: number;
size: number; // px
gradientColors?: string[];
viewedColor?: string;
}
interface RingPresentation {
avatarSize: number;
className: string;
style: Record<string, string>;
}