Stories Core

驱动 @reelkit/react-stories-player的引擎。纯 TypeScript,不依赖任何框架。可以用它为 Angular、Vue 或原生 JS 构建 Stories Player。

与框架无关
纯 TypeScript,不依赖任何 DOM 框架
两级导航
分组,以及每个分组内部的 story
Story 计时
基于核心计时器的自动播放,支持暂停与恢复
Canvas 进度条
适配 Retina 的分段进度条,带滑动窗口
点击区域
可配置的左右点击检测
响应式信号
构建在 @reelkit/core 的信号原语之上

安装

bash
npm i @reelkit/stories-core

Stories 控制器

createStoriesController(config, events?) 返回一个 StoriesController,负责在分组和 story 之间导航。它跟踪暂停 / 恢复状态,记住每个分组最后看到的 story,并在每次切换时触发回调。

配置(StoriesControllerConfig)

属性类型默认值说明
groupCountnumber必填story 分组总数
storyCountsnumber[]必填每个分组内的 story 数量
initialGroupIndexnumber0初始分组索引
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex),否则为 0分组内的初始 story 索引。显式指定时优先于任何记忆;省略则初始分组像其他分组一样恢复。
defaultImageDurationnumber5000图片类 story 的默认自动播放时长(毫秒)
resumeStoryIndex(groupIndex: number) => numberundefined尚未访问的分组打开时所在的 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.activeGroupIndexSignal<number>当前分组索引
state.activeStoryIndexSignal<number>分组内当前 story 的索引
state.isPausedSignal<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 变短时会把位置拉回有效范围内。

示例

typescript
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)

属性类型默认值说明
gapnumber2分段之间的间距(像素)
barHeightnumber2进度条高度(像素)
minSegmentWidthnumber8触发滑动窗口之前的最小分段宽度
bgColorstring'rgba(255,255,255,0.3)'未填充分段的背景色
fillColorstring'#ffffff'已完成 / 当前分段的填充色
onResize() => void-容器尺寸变化、canvas 重新测量之后调用。调整 canvas 尺寸会清空它,所以请在这里重绘。

方法

createCanvasProgressRenderer 返回一个 CanvasProgressRenderer,为保存渲染器的组件字段或 ref 标注类型时用的就是这个对象:

成员类型说明
attach(canvas)(HTMLCanvasElement) => void挂到 canvas 元素上;并开始监听父级的尺寸
draw(totalStories, activeIndex, progress)(number, number, number) => void按给定状态绘制进度条
widthnumber (readonly)当前测得的宽度(CSS 像素)
dispose()() => void停止监听父级的尺寸并清理内部状态

示例

typescript
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)

属性类型默认值说明
storageKeystring必填条目写入时使用的存储键
groups() => StoriesGroup<T>[]必填读取当前的分组。它是一个 getter,每次都会被调用,所以 feed 加载更多分组时只需把它们返回即可。请让它保持最新:一直返回第一个数组的 getter 会把之后加载的所有分组都算错。
keyUrlKey<Id, TwoAxisPosition>稳定 id位置在存储中的写法。传入 URL 控制器使用的 key,存储的条目读起来就和分享链接一样。
storageStorageAdapterlocalStorage条目保存的位置。自定义适配器也是接入你自己的已观看存储的方式。
ttlMsnumber永不过期分组的记录在最后一次记录之后保留多久。每个分组按自己的时钟过期。
maxTracksnumber全部保留保留多少个分组。超出后,在下一次写入时丢弃记录时间最早的分组。

storageKey、key 和 storage 只在控制器创建时读取一次。要切换存储,请另外创建一个控制器。

StoriesViewedStateController

成员类型说明
viewedStateSubscribable<Map<string, number>>按作者 id 统计的已看 story 数,以 signal 形式提供。attach() 之前为空。
controllerViewedStateController<TwoAxisPosition>底层的存储,用于这里没有覆盖的需求
resumeStoryIndex(groupIndex)(number) => number第一个未看的 story;分组已看完时为 0
markViewed(groupIndex, storyIndex)(number, number) => void把某条 story 记为已看。播放器会为每条显示过的 story 调用它。
forget()() => void清除所有已观看记录,包括存储中的
attach()() => Dispose读取存储并跟随其他标签页。带引用计数,因此圆环列表和播放器可以以任意顺序挂接和卸载。播放器组件会自己调用它。

示例

typescript
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, kStoriesCardRingSizenumber各绑定默认绘制的圆环直径(像素):单个圆环 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.

typescript
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>;
}