Vue 故事播放器

基于 @reelkit/vue-stories-player 的 Vue 3 Instagram 风格故事播放器覆盖层。它与 React 版本共用 @reelkit/stories-core 引擎,因此行为、CSS 类名和主题令牌完全一致。

查看在线演示 →

Features

嵌套导航
点击切换故事,滑动切换用户
视频故事
自动播放并可切换声音
自动播放
每条故事的计时器可配置
3D 转场
立方体、翻转、淡入淡出、缩放、滑动
进度条
基于 canvas 的分段进度条
图片与视频
两种媒体类型都支持
虚拟化
DOM 中仅保留 3 个幻灯片
双击点赞
双击触发爱心动画
桌面导航
箭头按钮
故事头像环
Instagram 风格的头像圆环
泛型类型
用自定义字段扩展 StoryItem
作用域插槽
可替换界面的任何部分
URL 状态
可分享的 ?story=group.story 链接
已看状态
已看圆环与续播状态可跨刷新保留

安装

bash
npm install @reelkit/vue-stories-player @reelkit/vue lucide-vue-next

在应用入口(或任意组件)中引入一次样式表:

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

默认头部和导航箭头使用 lucide-vue-next 的图标。如果你更喜欢别的图标库,可以通过 #header 和 #navigation 作用域插槽替换它们。

Vue 3.3 或更高版本

这些覆盖层是泛型组件,Vue 从 3.3 起才支持。在更旧的 Vue 上,运行时会丢掉它们的 props 和事件,所以本包把 vue 3.3 或更高版本列为 peer 依赖。

快速上手

StoriesOverlay 渲染全屏播放器。把它和 StoriesRingList 搭配作为 Instagram 风格的入口,传入一组 StoriesGroup 对象,并绑定 v-model:is-open。

vue
<script setup lang="ts">
import { ref } from 'vue';
import {
  StoriesOverlay,
  StoriesRingList,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';
import '@reelkit/vue-stories-player/styles.css';

const groups: StoriesGroup[] = [
  {
    author: { id: 'alice', name: 'Alice', avatar: '/alice.jpg', verified: true },
    stories: [
      { id: 's1', mediaType: 'image', src: '/story-1.jpg' },
      { id: 's2', mediaType: 'video', src: '/story-2.mp4', poster: '/poster-2.jpg' },
    ],
  },
  {
    author: { id: 'bob', name: 'Bob', avatar: '/bob.jpg' },
    stories: [{ id: 's3', mediaType: 'image', src: '/story-3.jpg' }],
  },
];

const isOpen = ref(false);
const groupIndex = ref(0);

const openGroup = (index: number) => {
  groupIndex.value = index;
  isOpen.value = true;
};
</script>

<template>
  <StoriesRingList :groups="groups" @select="openGroup" />

  <!-- v-model:is-open keeps the ref in sync however the viewer closes it:
       the ✕ button, a swipe down, Escape, or the last story finishing. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :initial-group-index="groupIndex"
  />
</template>

URL 状态

查看在线演示 →

StoriesUrlOverlay 是一个独立组件,它的打开状态存放在地址栏里。两个轴共用一个参数 —— ?story=<group>.<story> —— 因此正在播放的故事有一个可分享、可收藏、可用后退按钮关闭的链接。用 useOverlayUrlState 和 urlIndexTwoAxisKey 创建控制器,然后作为 controller 传入。

内置 key

故事是双轴的,所以要把双轴 key 展开到控制器里:urlIndexTwoAxisKey(按位置定位用户和故事)或 urlStableIdTwoAxisKey(用户按稳定 id 定位)—— 两者都从 @reelkit/vue-stories-player 重新导出。参见 URL 状态指南和 Core API。

vue
<script setup lang="ts">
import {
  StoriesUrlOverlay,
  useOverlayUrlState,
  urlIndexTwoAxisKey,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';
import { useVueRouterUrlAdapter } from '@reelkit/vue/vue-router-url-adapter';
import '@reelkit/vue-stories-player/styles.css';

const props = defineProps<{ groups: StoriesGroup[] }>();

// The outer axis is the group, the inner one the story inside it.
const stories = useOverlayUrlState({
  param: 'story',
  adapter: useVueRouterUrlAdapter(),
  ...urlIndexTwoAxisKey({
    outerCount: () => props.groups.length,
    innerCounts: () => props.groups.map((group) => group.stories.length),
  }),
});
</script>

<template>
  <!-- Opening is a link — the overlay reads the URL and opens itself. -->
  <RouterLink
    v-for="(group, index) in props.groups"
    :key="group.author.id"
    :to="`?story=${index}.0`"
  >
    {{ group.author.name }}
  </RouterLink>

  <StoriesUrlOverlay :controller="stories" :groups="props.groups" />
</template>
  • 打开会压入一条历史记录。在故事之间移动以及切换用户都只是替换它,因此 N 次导航不会新增记录,一次后退始终关闭播放器。后退是关闭,而不是回到上一条故事。
  • 内层导航也会写进 URL。 在同一用户的故事间前进会把 ?story=2.n 更新掉,因此深层链接能落到确切的那条故事。
  • 只有当播放器是在应用内部打开时,后退才会关闭它 —— 那次跳转压入了一条记录。直接在新标签页打开的分享链接背后没有历史记录,后退会离开站点;✕ 按钮或 Escape 会就地移除参数并留在页面上。
  • 指向不存在的用户或故事的参数 —— 过期书签、手改的值、超出该用户故事数量的序号 —— 会从 URL 中移除,而不是打开相邻的一条。
  • 使用路由的应用应该传入基于路由器的适配器,让路由器始终是导航状态的唯一来源。@reelkit/vue/vue-router-url-adapter 中的 useVueRouterUrlAdapter 就是 Vue Router 的现成适配器;绕过路由器直接写历史会让它的 location 过期,下一次导航就会丢掉参数。

稳定链接。 用户默认按位置定位,因此收藏的 ?story=2.0 在信息流重新排序后会打开另一个人。改用稳定 id 定位用户 —— outerCodec 负责把 id 写进 URL,outerLocator 负责找到它所在的位置。同时传入 innerCodec 以及 innerLocate 和 innerIdentify,就能对分组内的故事做同样的事;不传的话,故事那一半仍然是已解析分组内部的普通索引。故事 id 不能包含 .,它是两半之间的分隔符。

typescript
// `?story=alice.0` — the group by the author's stable id, the story by its
// position inside whichever group that id resolves to.
const stories = useOverlayUrlState({
  param: 'story',
  adapter: useVueRouterUrlAdapter(),
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.value.length,
    innerCounts: () => groups.value.map((group) => group.stories.length),
    outerCodec: { decode: (raw) => raw, encode: (id) => id },
    outerLocator: {
      locate: (id) =>
        groups.value.findIndex((group) => group.author.id === id),
      identify: (index) => groups.value[index].author.id,
    },
  }),
});

无限信息流。 分页属于 outerLocator 的职责,与编解码器无关。locate 是同步的,只能回答已加载的分组 —— 指向第 400 个分组、而只加载了 20 个的分享链接会落空。locateAsync 是兜底,只在 locate 未命中时调用;故事随后会按最终落到的那个分组重新约束。

同一个 locateAsync,外层轴

这与单轴 key 接受的 locateAsync 分页器是同一个 —— 在双轴 key 上它跟着你传入的 outerLocator 走,因此分页发生在用户这一轴,而故事仍是已解析分组内部的局部索引。

typescript
// A feed that pages. `locate` answers for the groups already loaded;
// `locateAsync` runs only when it misses, so a shared link to a group past
// the window fetches up to it and then opens.
outerLocator: {
  locate: (index) => (index < loaded.value.length ? index : null),
  identify: (index) => index,
  locateAsync: async (index) => {
    if (index >= totalGroups) return null;
    loaded.value = await fetchGroupsUpTo(index);
    return index;
  },
},
  • 在 locateAsync 等待期间,播放器保持关闭且参数原样保留,因此深层链接能熬过这次请求。返回 null 或拒绝则会移除参数。
  • 如果答复在 URL 已经变化之后、关闭之后或卸载之后才到达,就会被丢弃 —— 一次缓慢的请求无法打开没人再要求的故事。
  • useOverlayUrlState 的完整选项见 Vue API 参考。

记住看到哪里

在一位用户的故事被看完之前,头像环一直是渐变色;而分组会从观众还没看过的第一条故事重新打开。两者都来自同一个 createStoriesViewedStateController:在信息流所在的地方创建一次,作为 viewed 交给头像环列表和播放器 —— 它们会自己读取存储、跟随它并记录已展示的内容。存储条目用 id 记录作者和故事,因此信息流重新排序后依然有效,同一个控制器在 React 和 Angular 中也同样可用。

vue
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';

const props = defineProps<{ groups: StoriesGroup[] }>();

const isOpen = ref(false);
const groupIndex = shallowRef(0);

// One controller for the rings and the player. It reads the groups through
// the getter every time it needs them, so a feed that pages in more groups
// is counted too.
const viewed = createStoriesViewedStateController({
  storageKey: 'stories-seen',
  groups: () => props.groups,
});

const openGroup = (index: number) => {
  groupIndex.value = index;
  isOpen.value = true;
};
</script>

<template>
  <!-- The rings follow the controller by themselves. -->
  <StoriesRingList
    :groups="props.groups"
    :viewed="viewed"
    @select="openGroup"
  />

  <!-- The player resumes, records, and draws its card rings from it. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="props.groups"
    :initial-group-index="groupIndex"
    :viewed="viewed"
  />
</template>
  • 打开时那条故事也算。 已经显示在屏幕上的故事会在播放器挂载后被标记为已看,因此打开只有一条故事的分组再关闭,就算看完了。
  • 继续往后滑同样会续播。 本次会话中首次到达的每个分组都会询问 resumeStoryIndex,包括播放器最初打开的那个,除非 initialStoryIndex 明确指定了故事;已经滑过的分组会回到上次离开的位置。
  • 链接依然优先。 使用 StoriesUrlOverlay 时,无论存储了什么,都由参数决定播放器从哪里打开。其他情况下由续播回调决定。
  • 计数是位置,不是总量。 条目记录的是到达过的最远一条故事,因此给已看完的分组新增故事会让它的圆环重新亮起,从中间删掉一条则会让计数变短。
  • 存储可替换。 传入 storage: createSessionStorageAdapter() 可在标签页关闭时忘记,或传入你自己的 StorageAdapter。两个打开的标签页通过浏览器的 storage 事件保持同步。
  • 只在打开时才挂载的播放器读得太晚。 覆盖层在挂载之后、仍然关闭的状态下读取存储。用 v-if="open" 时没有这个时机,因此请在持有控制器的组件里调用 useAttachViewedState(() => viewed);它在挂载后接入,在卸载时释放。相关选项以及它所基于的底层构件都在 Stories Core 页面。

设置 desktop-layout="carousel",播放器就会像 Instagram 网页版那样排布:当前故事以正常尺寸留在中间,两侧各有最多两个相邻分组作为更小、更暗的卡片。点击卡片会打开那个分组,切换分组时卡片会横向滑动。把交给头像环列表的同一个 viewed 控制器也传进来,已看完分组的卡片就会显示暗色圆环。卡片自己跟随它,因此某条故事被标记为已看时只有卡片重绘,播放器不会重新渲染。

vue
<template>
  <!-- Neighbouring groups sit beside the active story as dimmed cards on a
       desktop screen. Phones keep the plain player whatever this says. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :viewed="viewed"
    desktop-layout="carousel"
  />
</template>
  • 手机上保持普通播放器。 宽度在 768px 以内时不渲染卡片,滑动使用 group-transition,与默认的 'single' 布局一致。跨过这个宽度调整窗口会实时切换。
  • 每张卡片预览该分组将要打开的内容:本次会话中离开时的那条故事,否则是 resumeStoryIndex 返回的那条。它会显示视频封面或图片。两者都没有的故事(例如渐变背景上的纯文字故事)会由你的 #slide 插槽按播放器尺寸绘制后缩小;没有封面的视频则显示卡片背景。该预览拿到的是 isActive: false,它只是一张图:其中的内容无法聚焦,也无法点击。请把幻灯片挂载时会做的事情(例如播放或埋点)放在 isActive 判断之后。
  • 计时器会等待滑动结束。 在卡片停止移动之前,故事不会前进,story-viewed 也不会为它触发。
  • 每一种切换分组的方式都会触发滑动:点击卡片、StoriesApi.goToGroup、nextGroup 与 prevGroup,以及在分组末尾继续按方向键。触摸滑动本身已经移动了播放器,因此不会再叠加一次滑动。

每个分组自带进度条和头部

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

vue
<template>
  <!-- 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
    v-model:is-open="isOpen"
    :groups="groups"
    chrome-placement="group"
  />
</template>
  • 相邻分组显示它停在哪里。 播放器转向它时,它的进度条和头部显示它将打开的那个故事,尚未播放任何部分。
  • 正在离开的分组保持原样。 它的进度条在转动结束前保留原有进度,所以一个已播完的分组会保持填满,而不会在转动中途清空。
  • 屏幕上的每个分组都会渲染这些插槽。 #progressBar 和 #header 的作用域带有 groupIndex 和 isActive;对相邻分组,isActive 为 false,进度信号保持不变。
  • #header 插槽位于滑动区域内。 点击它会切换故事,除非点在 button、链接或带 role="button" 的元素上,所以其中的每个控件都应是这几类元素之一。
  • 桌面端轮播在滑动过程中不受影响。 卡片滑动时播放器是隐藏的,因此这个选项在分组打开后才会显现。

API 参考

StoriesOverlay 属性

StoriesOverlayProps<T>

Prop类型默认值说明
aria-labelstring'Stories player'对话框区域的无障碍名称;覆盖层打开时由屏幕阅读器朗读
chrome-placement'overlay' | 'group''overlay'进度条和头部放在哪里。'overlay' 在播放器上方绘制一份;'group' 在每个分组的幻灯片里各绘制一份,二者随分组一起转动。该类型以 ChromePlacement 导出。
default-image-durationnumber5000图片故事的自动播放时长,单位毫秒
desktop-layout'single' | 'carousel''single'桌面布局。'carousel' 会把相邻分组作为预览卡片显示在当前故事两侧,并在分组之间滑动。手机上始终只显示故事本身。该类型以 DesktopLayout 导出。
enable-keyboardbooleantrue启用键盘导航(左右方向键、Escape)
groupsStoriesGroup<T>[]required要展示的故事分组。播放器打开期间新增分组或故事都会被接管,无论是替换数组还是往原数组里追加。
group-transitionTransitionTransformFncubeTransition外层(分组)滑块的转场。桌面轮播显示时会被忽略,改为滑动。
hide-ui-on-pausebooleantrue长按暂停期间隐藏进度条和头部
initial-group-indexnumber0初始可见分组的下标,从 0 开始
initial-story-indexnumber续播,否则 0初始可见故事的下标,从 0 开始。明确指定时优先于任何已记住的位置;不传则该分组像其他分组一样续播。
inner-transition-durationnumber200故事之间的交叉淡入淡出时长,单位毫秒
is-openbooleanrequired控制可见性,并在打开期间锁定页面滚动。用 v-model:is-open 绑定。
min-segment-widthnumber8进度分段的最小宽度(像素),低于它就启用滑动窗口
resume-story-index(groupIndex: number) => number—某个分组第一次被到达时从哪条故事打开。只在本次打开期间尚未访问过的分组上询问,并且优先于 viewed。
tap-zone-splitnumber0.3点击区域的分割比例(0–1)。左侧部分后退,其余部分前进。
viewedStoriesViewedStateController—观众已看过的内容,来自 createStoriesViewedStateController()。分组会从第一条未看过的故事续播,每条展示过的故事都会被记录,轮播卡片也会把已看完分组的圆环变暗。请把同一个控制器传给 StoriesRingList。

StoriesUrlOverlay 属性

StoriesUrlOverlayProps<T>

接受上面的全部属性,除了打开状态三件套 —— is-open、initial-group-index、initial-story-index —— 它们改由控制器提供。

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

事件

事件载荷说明
@api-readyStoriesApi播放器挂载后触发,附带命令式 API
@closevoid播放器关闭时触发。在 StoriesUrlOverlay 上,此时参数已经被清除。
@double-tapgroupIndex: number, storyIndex: number双击时触发,与爱心动画同时发生
@group-changegroupIndex: number当前分组变化后触发
@pausevoid长按或点击头部暂停按钮时触发
@resumevoid松开或点击头部播放按钮时触发
@story-changegroupIndex: number, storyIndex: number当前故事变化后触发
@story-completegroupIndex: number, storyIndex: number故事的计时器走完时触发
@story-viewedgroupIndex: number, storyIndex: number故事变为可见时触发,包括打开时的那一条
@update:is-openboolean关闭时触发;用于支持 `v-model:is-open`

v-model:is-open

用 v-model:is-open 以单个绑定驱动覆盖层。如果你需要显式事件,:is-open + @close 的组合依然可用。

vue
<script setup lang="ts">
import { ref } from 'vue';
import { StoriesOverlay } from '@reelkit/vue-stories-player';

const isOpen = ref(false);
</script>

<template>
  <button type="button" @click="isOpen = true">Open stories</button>

  <!-- One binding, in both directions. -->
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups" />

  <!-- The explicit pair still works when you need the event itself. -->
  <StoriesOverlay
    :is-open="isOpen"
    :groups="groups"
    @close="isOpen = false"
  />
</template>

作用域插槽

八个作用域插槽可以替换播放器的任何部分。每个插槽都会收到带类型的作用域对象。没有传的插槽保持默认 —— 渲染结果为空的插槽同样如此,因此变为 false 的 v-if 会回退到默认,而不是留下空洞。

插槽作用域说明
#error{ story, storyIndex, groupIndex }替换绘制在当前故事之上的内置「Content unavailable」面板
#footer{ author, story, storyIndex }在当前分组的故事下方添加页脚
#groupPreview{ group, groupIndex, story, offset, viewedCount, onOpen }填充桌面轮播卡片的内容;定位、缩放和滑动仍由播放器负责
#header{ author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose }替换默认头部(作者、暂停、声音、关闭)。使用 chrome-placement="group" 时它位于滑动区域内,参见每个分组自带进度条和头部。
#loading{ story, storyIndex, groupIndex }在当前故事加载时替换默认的头部加载指示
#navigation{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }替换两侧的桌面箭头
#progressBar{ totalStories, activeIndex, progress, group, groupIndex, isActive }替换 canvas 进度条。activeIndex 和 progress 是核心信号 —— 请用 toVueRef 桥接。
#slide{ story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }替换默认的图片与视频幻灯片
vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Every slot is optional; one you leave out keeps the default, and so
         does one that renders nothing. -->
    <template #header="{ author, isPaused, onTogglePause, onClose }">
      <header class="my-header">
        <img :src="author.avatar" :alt="author.name" />
        <span>{{ author.name }}</span>
        <button type="button" @click="onTogglePause">
          {{ isPaused ? 'Play' : 'Pause' }}
        </button>
        <button type="button" @click="onClose">Close</button>
      </header>
    </template>

    <template #footer="{ author, story }">
      <form class="my-reply" @submit.prevent="reply(author, story)">
        <input v-model="draft" :placeholder="`Reply to ${author.name}…`" />
      </form>
    </template>
  </StoriesOverlay>
</template>

自定义幻灯片

在 #slide 内组合 ImageStorySlide 和 VideoStorySlide,既能画出自己的布局,又能保留自动播放、封面抓帧和声音同步。请从作用域里调用生命周期回调,否则计时器永远不会启动:

回调何时调用
onReady图片已加载,或视频开始播放。清除加载状态并启动计时器。
onWaiting视频在播放中缓冲。显示加载指示并暂停计时器。
onError内容加载失败。显示错误状态,并把该 URL 记为损坏,重新打开时不再重试。
onDurationReady上报真实的媒体时长(毫秒),让计时器与视频一样长。
onEnded媒体播放结束。前进到下一条故事。
vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Compose ImageStorySlide and VideoStorySlide to keep autoplay, poster
         capture and sound sync while drawing your own layout around them.
         Pass the callbacks through, or the timer never starts. -->
    <template #slide="scope">
      <div class="my-slide">
        <VideoStorySlide
          v-if="scope.story.mediaType === 'video'"
          :src="scope.story.src"
          :poster="scope.story.poster"
          :group-index="scope.groupIndex"
          :story-index="scope.index"
          :active-group-index="scope.activeGroupIndex"
          :active-story-index="scope.activeStoryIndex"
          :on-duration-ready="scope.onDurationReady"
          :on-playing="scope.onReady"
          :on-waiting="scope.onWaiting"
          :on-ended="scope.onEnded"
          :on-error="scope.onError"
        />
        <ImageStorySlide
          v-else
          :src="scope.story.src"
          :on-load="scope.onReady"
          :on-error="scope.onError"
        />

        <!-- A carousel card draws this same slot with isActive: false, and
             nothing in it can be clicked. Keep playback or analytics behind
             the flag. -->
        <span v-if="scope.isActive" class="my-badge">{{ scope.story.id }}</span>
      </div>
    </template>
  </StoriesOverlay>
</template>
预加载缓存

内置的 ImageStorySlide 和 VideoStorySlide 会在后台预加载下一条故事。当观众到达已预加载的故事时,内容会立即出现,不显示加载指示。失败过一次的 URL 也会被记住,因此重新打开损坏的故事会直接显示错误,而不是再试一次。

自定义导航

vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Replaces both desktop arrows at once. The first node sits left of
         the story, the rest to its right. -->
    <template #navigation="{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }">
      <div class="my-nav">
        <button type="button" @click="onPrevGroup">Previous user</button>
        <button type="button" @click="onPrevStory">Back</button>
      </div>
      <div class="my-nav">
        <button type="button" @click="onNextGroup">Next user</button>
        <button type="button" @click="onNextStory">Forward</button>
      </div>
    </template>
  </StoriesOverlay>
</template>

自定义进度条

作用域携带的是核心信号而不是普通数字,因此计时器可以驱动进度条而不重新渲染播放器。请用 @reelkit/vue 的 toVueRef 桥接:

vue
<script setup lang="ts">
import { toVueRef, type Signal } from '@reelkit/vue';

const props = defineProps<{
  totalStories: number;
  activeIndex: Signal<number>;
  progress: Signal<number>;
}>();

// Core signals do not re-render a Vue component on their own: bridge them.
const active = toVueRef(props.activeIndex);
const progress = toVueRef(props.progress);
</script>

<template>
  <div class="my-progress">
    <span
      v-for="index in totalStories"
      :key="index"
      class="my-segment"
      :style="{
        '--fill':
          index - 1 < active
            ? '100%'
            : index - 1 === active
              ? `${progress * 100}%`
              : '0%',
      }"
    />
  </div>
</template>

自定义加载与错误

vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Both are drawn over the active story only. Without the loading slot
         the default is the header spinner; without the error slot it is the
         built-in "Content unavailable" panel. -->
    <template #loading="{ story }">
      <p class="my-loading">Loading {{ story.mediaType }}…</p>
    </template>

    <template #error="{ story, groupIndex }">
      <div class="my-error">
        <p>That {{ story.mediaType }} would not load.</p>
        <button type="button" @click="report(groupIndex, story)">Report</button>
      </div>
    </template>
  </StoriesOverlay>
</template>
vue
<template>
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :viewed="viewed"
    desktop-layout="carousel"
  >
    <!-- The player still positions, scales and slides the card; this fills
         it. `offset` is negative on the left, positive on the right, and 0
         for the card passing through the center during a slide. -->
    <template #groupPreview="{ group, story, viewedCount, offset, onOpen }">
      <button type="button" class="my-card" @click="onOpen">
        <img v-if="story?.poster" :src="story.poster" alt="" />
        <span>{{ group.author.name }}</span>
        <small>{{ viewedCount }} / {{ group.stories.length }} seen</small>
        <em v-if="offset === 0">opening…</em>
      </button>
    </template>
  </StoriesOverlay>
</template>

转场

group-transition 控制切换用户时的 3D 效果。从 @reelkit/vue 导入转场函数:

vue
<script setup lang="ts">
import { StoriesOverlay } from '@reelkit/vue-stories-player';
import {
  cubeTransition,
  flipTransition,
  fadeTransition,
  zoomTransition,
  slideTransition,
} from '@reelkit/vue';
</script>

<template>
  <!-- cubeTransition is the default; flip, fade, zoom and slide ship too. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :group-transition="flipTransition"
  />
</template>

StoriesApi

在覆盖层上放一个模板 ref 即可命令式控制,或者从 @api-ready 事件拿到 API。播放器关闭期间的调用不会有任何效果。

vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { StoriesOverlay, type StoriesApi } from '@reelkit/vue-stories-player';

const isOpen = ref(false);
const player = useTemplateRef<StoriesApi>('player');

// Calls while the player is closed are safely ignored.
const skipUser = () => player.value?.nextGroup();
</script>

<template>
  <button type="button" @click="skipUser">Skip this user</button>

  <StoriesOverlay
    ref="player"
    v-model:is-open="isOpen"
    :groups="groups"
    @api-ready="(api) => api.pause()"
  />
</template>

方法

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

双击与点赞

双击会播放爱心动画,给出即时的视觉反馈。@double-tap 事件带上分组与故事下标,你可以把点赞保存在自己的状态里。播放器本身不保存点赞状态。

vue
<script setup lang="ts">
import { StoriesOverlay, type StoriesGroup } from '@reelkit/vue-stories-player';

const props = defineProps<{ groups: StoriesGroup[] }>();

// The heart animation is built in; the like itself is yours to keep.
const like = (groupIndex: number, storyIndex: number) => {
  const story = props.groups[groupIndex].stories[storyIndex];
  void fetch(`/api/stories/${story.id}/like`, { method: 'POST' });
};
</script>

<template>
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="props.groups"
    @double-tap="like"
  />
</template>

自定义爱心动画

用 --rk-stories-heart-duration 令牌调整速度(见主题)。要改颜色、大小或彻底隐藏爱心,直接作用于 .rk-stories-heart 类。HeartAnimation 组件也单独导出,可独立使用。

css
:root {
  /* Pop-in and fade-out duration. */
  --rk-stories-heart-duration: 500ms;
}

/* Colour and size are plain CSS on the element itself. */
.rk-stories-heart svg {
  width: 72px;
  height: 72px;
  fill: #ff4d6d;
}

/* Or hide it and animate your own from the double-tap event. */
.rk-stories-heart {
  display: none;
}

内置的爱心动画目前还不能通过插槽替换。你可以用 CSS 重新设置样式,或用 display: none 隐藏它,再从 @double-tap 事件播放自己的动画。如果你需要 heart 插槽,欢迎通过 GitHub Issues 告诉我们。

子组件

导出的构件,既可以在插槽内组合,也可以单独使用:StoriesRing、StoriesRingList、StoryHeader、CanvasProgressBar、ImageStorySlide、VideoStorySlide 和 HeartAnimation。

组件说明
CanvasProgressBar基于 canvas 的分段进度条,直接由计时器信号绘制。故事很多的分组会启用滑动窗口。传入 :live="false" 时只在信号变化和尺寸改变后重绘,没有动画循环。
HeartAnimation双击的爱心。动画结束时发出 complete,好让父组件把它移除。
ImageStorySlide使用 object-fit: cover 的满幅图片幻灯片,通过回调上报加载与错误。
StoriesRing带渐变圆环的圆形头像。两种状态:还有未看内容时旋转,全部看完后变为平色。发出 click。
StoriesRingList横向排列的头像环与作者名,每个分组一个。发出带分组下标的 select。
StoryHeader默认头部:头像、名称、认证标记、相对时间、暂停、声音、关闭,以及加载指示。
VideoStorySlide由唯一共享 <video> 元素驱动的视频幻灯片,用于 iOS 上的声音连续性。负责自动播放、封面帧和时长上报。
vue
<script setup lang="ts">
import {
  CanvasProgressBar,
  HeartAnimation,
  ImageStorySlide,
  StoriesRing,
  StoriesRingList,
  StoryHeader,
  VideoStorySlide,
} from '@reelkit/vue-stories-player';
</script>

<template>
  <!-- One ring, and the horizontal row of them. -->
  <StoriesRing
    :author="group.author"
    :total-stories="group.stories.length"
    :viewed-count="2"
    :size="72"
    @click="open(group)"
  />
  <StoriesRingList :groups="groups" :viewed="viewed" @select="open" />

  <!-- Inside a #header slot: the default header, with your own additions. -->
  <StoryHeader
    :author="group.author"
    :created-at="story.createdAt"
    :is-paused="isPaused"
    :is-video="story.mediaType === 'video'"
    :is-muted="isMuted"
    :on-toggle-pause="onTogglePause"
    :on-toggle-sound="onToggleSound"
    :on-close="onClose"
  />

  <!-- Inside a #progressBar slot: the canvas bar, driven by the signals. -->
  <CanvasProgressBar
    :total-stories="group.stories.length"
    :active-index="activeIndex"
    :progress="progress"
    :min-segment-width="12"
  />

  <!-- Inside a #slide slot; see the slide slot example above. -->
  <ImageStorySlide :src="story.src" :on-load="onReady" :on-error="onError" />
  <VideoStorySlide
    :src="story.src"
    :group-index="groupIndex"
    :story-index="index"
    :active-group-index="activeGroupIndex"
    :active-story-index="activeStoryIndex"
  />

  <!-- Standalone, outside the player. -->
  <HeartAnimation @complete="remove(heart.id)" />
</template>

重新导出

使用者需要的一切都来自这个包,因此永远不必直接引入 @reelkit/core。

来源重新导出的内容
@reelkit/vueObserve、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-corecreateStoriesViewedStateController、StoriesViewedStateController、StoriesViewedStateControllerConfig,以及内容类型 StoryItem、AuthorInfo、StoriesGroup、MediaType

Observe 是播放器内部使用的渲染边界:核心信号变化时它只重新渲染自己的插槽,故事切换正是这样只刷新头部和进度条,而不重绘两个滑块。当整个组件通过 toVueRef 跟随信号显得过粗时,就在自定义插槽里用它 —— 参见 Vue API 参考。

每个组件的属性类型也都有导出:StoriesOverlayProps、StoriesUrlOverlayProps、StoriesRingProps、StoriesRingListProps、StoryHeaderProps、CanvasProgressBarProps、ImageStorySlideProps 和 VideoStorySlideProps。

类型

StoryItem、AuthorInfo、StoriesGroup

typescript
interface StoryItem {
  /** Unique within its group. Stored viewed state keys on it. */
  id: string;
  mediaType: 'image' | 'video';
  src: string;
  /** Poster for a video, also used as the carousel card preview. */
  poster?: string;
  /** Overrides defaultImageDuration for this story, in milliseconds. */
  duration?: number;
  createdAt?: string | Date;
  /** Media aspect ratio (width / height). */
  aspectRatio?: number;
}

interface AuthorInfo {
  /** Stable id. Viewed state and stable-id URLs key on it. */
  id: string;
  name: string;
  avatar: string;
  verified?: boolean;
}

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

插槽作用域

每个作用域类型都有导出,因此在编译器无法推断时可以显式标注插槽 —— HeaderSlotScope、FooterSlotScope、SlideSlotScope、NavigationSlotScope、ProgressBarSlotScope、LoadingSlotScope、ErrorSlotScope 和 GroupPreviewSlotScope,以及 StoriesApi:

typescript
interface HeaderSlotScope<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
  isPaused: boolean;
  isMuted: boolean;
  /** True while the active story is a video — gate your sound button on it. */
  isVideo: boolean;
  groupIndex: number;
  /** False for a neighbouring group's header with chrome-placement="group". */
  isActive: boolean;
  onToggleSound: () => void;
  onTogglePause: () => void;
  onClose: () => void;
}

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

interface SlideSlotScope<T extends StoryItem = StoryItem> {
  story: T;
  index: number;
  groupIndex: number;
  /** False for the copy a carousel card draws. */
  isActive: boolean;
  size: [number, number];
  activeGroupIndex: Signal<number>;
  activeStoryIndex: Signal<number>;
  /** Report the real media length, in milliseconds. */
  onDurationReady: (durationMs: number) => void;
  onReady: () => void;
  onWaiting: () => void;
  onError: () => void;
  onEnded: () => void;
}

interface NavigationSlotScope {
  onPrevStory: () => void;
  onNextStory: () => void;
  onPrevGroup: () => void;
  onNextGroup: () => void;
}

interface ProgressBarSlotScope<T extends StoryItem = StoryItem> {
  totalStories: number;
  activeIndex: Signal<number>;
  /** 0 to 1 for the active story. Bridge it with toVueRef. */
  progress: Signal<number>;
  group: StoriesGroup<T>;
  groupIndex: number;
  /** False: the signals hold still for a neighbouring group. */
  isActive: boolean;
}

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

/** Same shape as LoadingSlotScope. */
interface ErrorSlotScope<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

interface GroupPreviewSlotScope<T extends StoryItem = StoryItem> {
  group: StoriesGroup<T>;
  groupIndex: number;
  /** The story the group would open on, if it has one. */
  story: T | undefined;
  /** Negative left, positive right, 0 while sliding through the center. */
  offset: number;
  viewedCount: number;
  onOpen: () => void;
}

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

自定义故事类型

用你自己的字段扩展 StoryItem,并用它标注分组。StoriesOverlay 和 StoriesUrlOverlay 是泛型组件,与 React 播放器一致:故事类型从 groups 推断,所以故事不符合 StoryItem 的分组无法通过类型检查。在 Vue 中插槽无法携带这个类型,插槽作用域在你标注之前没有类型。给插槽作用域加上类型标注,额外字段在模板中就依然可见。这个标注不会与 groups 核对,所以请让两者保持一致:

vue
<script setup lang="ts">
import { ref } from 'vue';
import {
  StoriesOverlay,
  type SlideSlotScope,
  type StoriesGroup,
  type StoryItem,
} from '@reelkit/vue-stories-player';

const isOpen = ref(false);

interface PromoStory extends StoryItem {
  title?: string;
  ctaHref?: string;
}

const groups: StoriesGroup<PromoStory>[] = [
  {
    author: { id: 'shop', name: 'Shop', avatar: '/shop.jpg' },
    stories: [
      {
        id: 'sale',
        mediaType: 'image',
        src: '/sale.jpg',
        title: 'Spring sale',
        ctaHref: '/sale',
      },
    ],
  },
];
</script>

<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Annotate the scope with your own item type to keep the slot typed. -->
    <template #slide="scope: SlideSlotScope<PromoStory>">
      <article class="promo">
        <h2>{{ scope.story.title }}</h2>
        <a v-if="scope.story.ctaHref" :href="scope.story.ctaHref">Shop now</a>
      </article>
    </template>
  </StoriesOverlay>
</template>

CSS 类名

类名是普通类名(非 scoped),因此在 @reelkit/vue-stories-player/styles.css 之后加载的样式表可以覆盖其中任何一个。它们与 @reelkit/react-stories-player 完全一致,所以为一种绑定写的样式表对另一种同样有效。颜色、尺寸和 z-index 请优先使用主题一节中的自定义属性。

类名组件说明
.rk-stories-overlayOverlay固定定位的全屏背景(背景色、z-index)
.rk-stories-overlay--carouselOverlay正在显示桌面轮播的覆盖层
.rk-stories-overlay--slidingOverlay轮播滑动进行中;播放器画布被隐藏
.rk-stories-swipe-wrapperOverlay下滑关闭的包装层(承载导航按钮与画布)
.rk-stories-containerOverlay圆角的故事画布(定位、overflow)
.rk-stories-ui-layerOverlay界面容器(头部、进度)。使用 chrome-placement="group" 时位于每个分组的幻灯片内
.rk-stories-ui-layer--hiddenOverlay界面隐藏状态(由 hide-ui-on-pause 切换)
.rk-stories-errorOverlay错误状态(居中的图标与文字)
.rk-stories-error-textOverlay错误提示文字
.rk-stories-nav-btnNavigation桌面端的前进/后退箭头
.rk-stories-progress-barCanvasProgressBarcanvas 进度条的定位包装层
.rk-stories-slide-wrapperGroup一个故事分组(外层幻灯片)
.rk-stories-storyStory单条故事(内层幻灯片根节点)
.rk-stories-headerStoryHeader头部栏(头像、名称、操作)
.rk-stories-header--hiddenStoryHeader头部隐藏状态
.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图片故事元素
.rk-stories-videoVideoStorySlide视频故事容器
.rk-stories-video-elementVideoStorySlide共享的 <video> 元素
.rk-stories-video-posterVideoStorySlide视频封面图(播放时淡出)
.rk-stories-video-poster--visibleVideoStorySlide封面可见状态(播放开始前)
.rk-stories-heartHeartAnimation双击爱心的弹出动画
.rk-stories-ringStoriesRing故事圆环(带动画渐变边框的头像)
.rk-stories-ring--activeStoriesRing还有未看内容的圆环(会动)
.rk-stories-ring-avatarStoriesRing圆环内部的头像图片
.rk-stories-ring-listStoriesRingList横向圆环列表容器
.rk-stories-ring-list-itemStoriesRingList圆环 + 名称的一列
.rk-stories-ring-list-nameStoriesRingList每个圆环下方的作者名称
.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没有封面或图片的故事,由 #slide 插槽按播放器尺寸绘制后缩小
.rk-stories-card-scrimCarousel画面上的压暗层
.rk-stories-card-infoCarousel圆环、作者名与时间的一列
.rk-stories-card-nameCarousel作者名称
.rk-stories-card-timeCarousel相对时间文字

主题

每个颜色、尺寸、z-index 和过渡都放在 CSS 自定义属性里,默认值定义在 :root 上。在 :root 或覆盖层的任意祖先上覆盖其中一个或多个,就能在不改组件源码的情况下换主题。这些令牌与 @reelkit/react-stories-player 一致,因此主题可以在两种绑定之间通用。

令牌默认值控制
--rk-stories-overlay-bg#000全屏背景颜色
--rk-stories-overlay-z9999覆盖层 z-index
--rk-stories-container-radius12px故事画布的圆角(桌面端)
--rk-stories-container-radius-mobile0手机上(宽度不超过 768px)故事画布的圆角
--rk-stories-swipe-gap16px导航按钮与故事画布之间的间距
--rk-stories-top-shade-height120px头部背后顶部渐变遮罩的高度
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)顶部渐变遮罩的颜色
--rk-stories-ui-transition200mshide-ui-on-pause 切换时的淡入淡出时长
--rk-stories-ui-z15UI 层(进度条和头部)的 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-nav-z20桌面导航按钮的 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-text-size13px错误提示字号
--rk-stories-error-gap12px错误图标与文字之间的间距
--rk-stories-error-z5错误状态的 z-index
--rk-stories-video-bg#000<video> 背后的黑边背景
--rk-stories-video-poster-transition200ms视频开始播放时封面的淡出时长
--rk-stories-progress-bar-padding8px 8px 0进度条周围的内边距
--rk-stories-progress-bar-z10进度条的 z-index
--rk-stories-header-top18px头部距故事顶部的纵向偏移
--rk-stories-header-padding12px 16px头部行的内边距
--rk-stories-header-z5头部的 z-index
--rk-stories-header-transition200ms头部隐藏时的淡出时长
--rk-stories-header-gap8px头像、名称与时间之间的间距
--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-btn-fg#fff头部操作图标颜色(关闭、静音、暂停)
--rk-stories-header-btn-padding4px头部操作按钮的内边距
--rk-stories-header-actions-gap8px头部操作按钮之间的间距
--rk-stories-header-spinner-size20px加载圈的直径
--rk-stories-header-spinner-fg#fff加载圈的弧线颜色
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)加载圈的轨道颜色
--rk-stories-header-spinner-duration0.8s加载圈的旋转时长
--rk-stories-heart-duration800ms爱心弹出与淡出的动画时长
--rk-stories-heart-z20双击爱心的 z-index
--rk-stories-ring-spin-duration4s还有未看内容时圆环的旋转时长
--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轮播滑动时长
css
/* Loaded after @reelkit/vue-stories-player/styles.css. Every token has a
   default on :root, so override only what you want to change. The names
   match @reelkit/react-stories-player, so a theme ports between bindings. */
:root {
  --rk-stories-overlay-bg: #0f172a;
  --rk-stories-container-radius: 24px;

  --rk-stories-nav-bg: rgba(99, 102, 241, 0.35);
  --rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.65);
  --rk-stories-nav-fg: #fff;

  --rk-stories-header-name-fg: #fef3c7;
  --rk-stories-header-time-fg: rgba(254, 243, 199, 0.7);

  --rk-stories-ring-spin-duration: 2s;
  --rk-stories-heart-duration: 600ms;

  --rk-stories-card-bg: #1e293b;
  --rk-stories-card-transition: 260ms;
}

/* 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")。设置 aria-label 可以改变屏幕阅读器朗读的内容,默认是「Stories player」。

覆盖层在打开时捕获焦点,关闭时把焦点还给触发元素。Tab 与 Shift+Tab 在内部的可聚焦元素之间循环;跑到外面的焦点会被拉回来。实现基于 @reelkit/core 的 captureFocusForReturn 和 createFocusTrap,并由 @reelkit/vue 重新导出。

使用桌面轮播时,每张默认卡片都是一个按钮,标签为「Open stories by」加作者名。卡片在 Tab 顺序中排在播放器控件之后,滑动期间则被移出 Tab 顺序;打开自己分组的那张卡片会把焦点交还给对话框,而不是丢在页面上。

服务端渲染

关闭状态下覆盖层什么也不渲染,StoriesRingList 在服务端把每个圆环都渲染成未看状态:已看存储只在挂载之后读取,因此服务端标记与首次客户端渲染一致。更完整的说明见 SSR 指南。

键盘快捷键

按键操作
ArrowLeft上一条故事。在分组的第一条故事上时,打开上一个分组,停在离开时的位置;在第一个分组上什么也不做。
ArrowRight下一条故事。越过分组的最后一条故事时,打开下一个分组;在最后一个分组上则关闭播放器。
Escape关闭播放器