Vue 故事播放器
基于 @reelkit/vue-stories-player 的 Vue 3 Instagram 风格故事播放器覆盖层。它与 React 版本共用 @reelkit/stories-core 引擎,因此行为、CSS 类名和主题令牌完全一致。
Features
安装
npm install @reelkit/vue-stories-player @reelkit/vue lucide-vue-next在应用入口(或任意组件)中引入一次样式表:
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。
<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 传入。
<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 不能包含 .,它是两半之间的分隔符。
// `?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 走,因此分页发生在用户这一轴,而故事仍是已解析分组内部的局部索引。
// 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 中也同样可用。
<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 控制器也传进来,已看完分组的卡片就会显示暗色圆环。卡片自己跟随它,因此某条故事被标记为已看时只有卡片重绘,播放器不会重新渲染。
<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 那样随分组一起转动。
<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-label | string | 'Stories player' | 对话框区域的无障碍名称;覆盖层打开时由屏幕阅读器朗读 |
chrome-placement | 'overlay' | 'group' | 'overlay' | 进度条和头部放在哪里。'overlay' 在播放器上方绘制一份;'group' 在每个分组的幻灯片里各绘制一份,二者随分组一起转动。该类型以 ChromePlacement 导出。 |
default-image-duration | number | 5000 | 图片故事的自动播放时长,单位毫秒 |
desktop-layout | 'single' | 'carousel' | 'single' | 桌面布局。'carousel' 会把相邻分组作为预览卡片显示在当前故事两侧,并在分组之间滑动。手机上始终只显示故事本身。该类型以 DesktopLayout 导出。 |
enable-keyboard | boolean | true | 启用键盘导航(左右方向键、Escape) |
groups | StoriesGroup<T>[] | required | 要展示的故事分组。播放器打开期间新增分组或故事都会被接管,无论是替换数组还是往原数组里追加。 |
group-transition | TransitionTransformFn | cubeTransition | 外层(分组)滑块的转场。桌面轮播显示时会被忽略,改为滑动。 |
hide-ui-on-pause | boolean | true | 长按暂停期间隐藏进度条和头部 |
initial-group-index | number | 0 | 初始可见分组的下标,从 0 开始 |
initial-story-index | number | 续播,否则 0 | 初始可见故事的下标,从 0 开始。明确指定时优先于任何已记住的位置;不传则该分组像其他分组一样续播。 |
inner-transition-duration | number | 200 | 故事之间的交叉淡入淡出时长,单位毫秒 |
is-open | boolean | required | 控制可见性,并在打开期间锁定页面滚动。用 v-model:is-open 绑定。 |
min-segment-width | number | 8 | 进度分段的最小宽度(像素),低于它就启用滑动窗口 |
resume-story-index | (groupIndex: number) => number | — | 某个分组第一次被到达时从哪条故事打开。只在本次打开期间尚未访问过的分组上询问,并且优先于 viewed。 |
tap-zone-split | number | 0.3 | 点击区域的分割比例(0–1)。左侧部分后退,其余部分前进。 |
viewed | StoriesViewedStateController | — | 观众已看过的内容,来自 createStoriesViewedStateController()。分组会从第一条未看过的故事续播,每条展示过的故事都会被记录,轮播卡片也会把已看完分组的圆环变暗。请把同一个控制器传给 StoriesRingList。 |
StoriesUrlOverlay 属性
StoriesUrlOverlayProps<T>
接受上面的全部属性,除了打开状态三 件套 —— is-open、initial-group-index、initial-story-index —— 它们改由控制器提供。
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
controller | UrlStateController<TwoAxisPosition> | required | 来自 useOverlayUrlState 并展开了 urlIndexTwoAxisKey 的控制器。它的 position —— 一个 { outer, inner } 对象 —— 决定播放器是否打开以及从哪里打开;覆盖层会在每次导航和关闭时写回。 |
事件
| 事件 | 载荷 | 说明 |
|---|---|---|
| @api-ready | StoriesApi | 播放器挂载后触发,附带命令式 API |
| @close | void | 播放器关闭时触发。在 StoriesUrlOverlay 上,此时参数已经被清除。 |
| @double-tap | groupIndex: number, storyIndex: number | 双击时触发,与爱心动画同时发生 |
| @group-change | groupIndex: number | 当前分组变化后触发 |
| @pause | void | 长按或点击头部暂停按钮时触发 |
| @resume | void | 松开或点击头部播放按钮时触发 |
| @story-change | groupIndex: number, storyIndex: number | 当前故事变化后触发 |
| @story-complete | groupIndex: number, storyIndex: number | 故事的计时器走完时触发 |
| @story-viewed | groupIndex: number, storyIndex: number | 故事变为可见时触发,包括打开时的那一条 |
| @update:is-open | boolean | 关闭时触发;用于支持 `v-model:is-open` |
v-model:is-open
用 v-model:is-open 以单个绑定驱动覆盖层。如果你需要显式事件,:is-open + @close 的组合依然可用。
<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 } | 替换默认的图片与视频幻灯片 |
<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 | 媒体播放结束。前进到下一条故事。 |
<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 也会被记住,因此重新打开损坏的故事会直接显示错误,而不是再试一次。
自定义导航
<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 桥接:
<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>自定义加载与错误
<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>自定义轮播卡片
<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 导入转场函数:
<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。播放器关闭期间的调用不会有任何效果。
<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 事件带上分组与故事下标,你可以把点赞保存在自己的状态里。播放器本身不保存点赞状态。
<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 组件也单独导出,可独立使用。
: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 上的声音连续性。负责自动播放、封面帧和时长上报。 |
<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/vue | Observe、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 |
Observe 是播放器内部使用的渲染边界:核心信号变化时它只重新渲染自己的插槽,故事切换正是这样只刷新头部和进度条,而不重绘两个滑块。当整个组件通过 toVueRef 跟随信号显得过粗时,就在自定义插槽里用它 —— 参见 Vue API 参考。
每个组件的属性类型也都有导出:StoriesOverlayProps、StoriesUrlOverlayProps、StoriesRingProps、StoriesRingListProps、StoryHeaderProps、CanvasProgressBarProps、ImageStorySlideProps 和 VideoStorySlideProps。
类型
StoryItem、AuthorInfo、StoriesGroup
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:
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 核对,所以请让两者保持一致:
<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-overlay | Overlay | 固定定位的全屏背景(背景色、z-index) |
.rk-stories-overlay--carousel | Overlay | 正在显示桌面轮播的覆盖层 |
.rk-stories-overlay--sliding | Overlay | 轮播滑动进行中;播放器画布被隐藏 |
.rk-stories-swipe-wrapper | Overlay | 下滑关闭的包装层(承载导航按钮与画布) |
.rk-stories-container | Overlay | 圆角的故事画布(定位、overflow) |
.rk-stories-ui-layer | Overlay | 界面容器(头部、进度)。使用 chrome-placement="group" 时位于每个分组的幻灯片内 |
.rk-stories-ui-layer--hidden | Overlay | 界面隐藏状态(由 hide-ui-on-pause 切换) |
.rk-stories-error | Overlay | 错误状态(居中的图标与文字) |
.rk-stories-error-text | Overlay | 错误提示文字 |
.rk-stories-nav-btn | Navigation | 桌面端的前进/后退箭头 |
.rk-stories-progress-bar | CanvasProgressBar | canvas 进度条的定位包装层 |
.rk-stories-slide-wrapper | Group | 一个故事分组(外层幻灯片) |
.rk-stories-story | Story | 单条故事(内层幻灯片根节点) |
.rk-stories-header | StoryHeader | 头部栏(头像、名称、操作) |
.rk-stories-header--hidden | StoryHeader | 头部隐藏状态 |
.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 | 图片故事元素 |
.rk-stories-video | VideoStorySlide | 视频故事容器 |
.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 | 故事圆环(带动画渐变边框的头像) |
.rk-stories-ring--active | StoriesRing | 还有未看内容的圆环(会动) |
.rk-stories-ring-avatar | StoriesRing | 圆环内部的头像图片 |
.rk-stories-ring-list | StoriesRingList | 横向圆环列表容器 |
.rk-stories-ring-list-item | StoriesRingList | 圆环 + 名称的一列 |
.rk-stories-ring-list-name | StoriesRingList | 每个圆环下方的作者名称 |
.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 | 没有封面或图片的故事,由 #slide 插槽按播放器尺寸绘制后缩小 |
.rk-stories-card-scrim | Carousel | 画面上的压暗层 |
.rk-stories-card-info | Carousel | 圆环、作者名与时间的一列 |
.rk-stories-card-name | Carousel | 作者名称 |
.rk-stories-card-time | Carousel | 相对时间文字 |
主题
每个颜色、尺寸、z-index 和过渡都放在 CSS 自定义属性里,默认值定义在 :root 上。在 :root 或覆盖层的任意祖先上覆盖其中一个或多个,就能在不改组件源码的情况下换主题。这些令牌与 @reelkit/react-stories-player 一致,因此主题可以在两种绑定之间通用。
| 令牌 | 默认值 | 控制 |
|---|---|---|
--rk-stories-overlay-bg | #000 | 全屏背景颜色 |
--rk-stories-overlay-z | 9999 | 覆盖层 z-index |
--rk-stories-container-radius | 12px | 故事画布的圆角(桌面端) |
--rk-stories-container-radius-mobile | 0 | 手机上(宽度不超过 768px)故事画布的圆角 |
--rk-stories-swipe-gap | 16px | 导航按钮与故事画布之间的间距 |
--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-ui-transition | 200ms | hide-ui-on-pause 切换时的淡入淡出时长 |
--rk-stories-ui-z | 15 | UI 层(进度条和头部)的 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-nav-z | 20 | 桌面导航按钮的 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-text-size | 13px | 错误提示字号 |
--rk-stories-error-gap | 12px | 错误图标与文字之间的间距 |
--rk-stories-error-z | 5 | 错误状态的 z-index |
--rk-stories-video-bg | #000 | <video> 背后的黑边背景 |
--rk-stories-video-poster-transition | 200ms | 视频开始播放时封面的淡出时长 |
--rk-stories-progress-bar-padding | 8px 8px 0 | 进度条周围的内边距 |
--rk-stories-progress-bar-z | 10 | 进度条的 z-index |
--rk-stories-header-top | 18px | 头部距故事顶部的纵向偏移 |
--rk-stories-header-padding | 12px 16px | 头部行的内边距 |
--rk-stories-header-z | 5 | 头部的 z-index |
--rk-stories-header-transition | 200ms | 头部隐藏时的淡出时长 |
--rk-stories-header-gap | 8px | 头像、名称与时间之间的间距 |
--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-btn-fg | #fff | 头部操作图标颜色(关闭、静音、暂停) |
--rk-stories-header-btn-padding | 4px | 头部操作按钮的内边距 |
--rk-stories-header-actions-gap | 8px | 头部操作按钮之间的间距 |
--rk-stories-header-spinner-size | 20px | 加载圈的直径 |
--rk-stories-header-spinner-fg | #fff | 加载圈的弧线颜色 |
--rk-stories-header-spinner-track | rgba(255, 255, 255, 0.3) | 加载圈的轨道颜色 |
--rk-stories-header-spinner-duration | 0.8s | 加载圈的旋转时长 |
--rk-stories-heart-duration | 800ms | 爱心弹出与淡出的动画时长 |
--rk-stories-heart-z | 20 | 双击爱心的 z-index |
--rk-stories-ring-spin-duration | 4s | 还有未看内容时圆环的旋转时长 |
--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 | 轮播滑动时长 |
/* 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 | 关闭播放器 |