Angular Stories Player
面向 Angular 的 Instagram 风格 Stories 播放浮层,使用 @reelkit/angular-stories-player。它与 React、Vue 播放器共用 @reelkit/stories-core 引擎,因此行为、CSS 类名和主题令牌完全一致。
特性
安装
npm install @reelkit/angular-stories-player @reelkit/angular lucide-angular样式表只需注册一次:
// Anywhere that runs at startup — main.ts, app.config.ts, a component file.
import '@reelkit/angular-stories-player/styles.css';
// Or, if you keep CSS out of TypeScript, from your global stylesheet:
// @import '@reelkit/angular-stories-player/styles.css';
// Or register it in angular.json:
// "styles": ["node_modules/@reelkit/angular-stories-player/styles.css"]图标
默认的头部和导航箭头使用 lucide-angular 提供图标。如果你更喜欢别的图标库,可以通过 rkStoriesHeader 和 rkStoriesNavigation 模板插槽替换它们。
快速上手
rk-stories-overlay 渲染全屏播放器。把它和 rk-stories-ring-list 搭配使用,就能得到 Instagram 风格的入口:圆环会带着分组索引发出 (selected),宿主把它保存为 initialGroupIndex,再设置 isOpen。播放器从不自行关闭;它发出 (closed),由宿主把 isOpen 设回 false。
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
RkStoriesOverlayComponent,
RkStoriesRingListComponent,
type StoriesGroup,
} from '@reelkit/angular-stories-player';
@Component({
selector: 'app-feed',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RkStoriesOverlayComponent, RkStoriesRingListComponent],
template: `
<rk-stories-ring-list [groups]="groups" (selected)="open($event)" />
<!-- The player reports a close instead of closing itself, so the host
decides: the ✕ button, a swipe down, Escape and the last story
finishing all arrive as (closed). -->
<rk-stories-overlay
[isOpen]="isOpen()"
[groups]="groups"
[initialGroupIndex]="groupIndex()"
(closed)="isOpen.set(false)"
/>
`,
})
export class FeedComponent {
protected readonly 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' }],
},
];
protected readonly isOpen = signal(false);
protected readonly groupIndex = signal(0);
protected open(index: number): void {
this.groupIndex.set(index);
this.isOpen.set(true);
}
}覆盖层渲染在哪里
React 和 Vue 播放器会把覆盖层移到 document.body 末尾,而 rk-stories-overlay 就渲染在你放置它的位置,并用 position: fixed 铺满屏幕。不要把它放在带有 transform、perspective、filter 或 will-change: transform 的祖先元素里:这样的祖先会成为覆盖层的包含块,播放器的尺寸会跟随它而不是视口,并被它的 overflow 裁剪。创建了自身层叠上下文的祖先,也会限制 --rk-stories-overlay-z 能把播放器抬到多高。反过来,设置在任意祖先上的 --rk-stories-* 令牌都能作用到播放器。
URL 状态
查看在线演示 →RkStoriesUrlOverlayComponent 是一个独立组件,它的打开状态存放在地址栏里。两个轴共用一个参数 —— ?story=<group>.<story> —— 因此正在播放的 故事有一个可分享、可收藏、可用后退按钮关闭的链接。用 createOverlayUrlState 和 urlIndexTwoAxisKey 创建控制器,然后作为 controller 传入。
import { ChangeDetectionStrategy, Component, Injector, inject, runInInjectionContext, type OnInit } from '@angular/core';
import {
RkStoriesUrlOverlayComponent,
RkStoriesRingListComponent,
type StoriesGroup,
} from '@reelkit/angular-stories-player';
import {
createOverlayUrlState,
urlIndexTwoAxisKey,
type TwoAxisPosition,
type UrlStateController,
} from '@reelkit/angular';
import { createRouterUrlAdapter } from '@reelkit/angular/ng-router-url-adapter';
@Component({
selector: 'app-url-feed',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RkStoriesUrlOverlayComponent, RkStoriesRingListComponent],
template: `
<rk-stories-ring-list [groups]="groups" (selected)="open($event)" />
<rk-stories-url-overlay [controller]="stories" [groups]="groups" />
`,
})
export class UrlFeedComponent implements OnInit {
private readonly injector = inject(Injector);
protected groups: StoriesGroup[] = [];
protected stories!: UrlStateController<TwoAxisPosition>;
ngOnInit(): void {
// Both the controller and the router adapter need an injection context,
// and ngOnInit does not run in one, so borrow the component's.
this.stories = runInInjectionContext(this.injector, () =>
createOverlayUrlState({
param: 'story',
adapter: createRouterUrlAdapter(),
...urlIndexTwoAxisKey({
outerCount: () => this.groups.length,
innerCounts: () => this.groups.map((group) => group.stories.length),
}),
}),
) as UrlStateController<TwoAxisPosition>;
}
// Swiping only replaces the entry, so one back step always closes the
// player rather than walking back through every story.
protected open(groupIndex: number): void {
this.stories.set(`${groupIndex}.0`);
}
}- 打开会压入一条历史记录。在故事之间移动以及切换用户都只是替换它,因此 N 次导航不会新增记录,一次后退始终关闭播放器。后退是关闭,而不是回到上一条故事。
- 内层导航也会写进 URL。 在同一用户的故事间前进会把
?story=2.n 更新掉,因此深层链接能落到确切的那条故事。 - 只有当播放器是在应用内部打开时,后退才会关闭它 —— 那次跳转压入了一条记录。直接在新标签页打开的分享链接背后没有历史记录,后退会离开站点;✕ 按钮或 Escape 会就地移除参数并留在页面上。
- 指向不存在的用户或故事的参数 —— 过期书签、手改的值、超出该用户故事数量的序号 —— 会从 URL 中移除,而不是打开相邻的一条。
- 使用路由的应用应该传入基于路由器的适配器,让路由器始终是导航状态的唯一来源。
@reelkit/angular/ng-router-url-adapter中的createRouterUrlAdapter就是 Angular 路由器的现成适配器;绕过路由器直接写历史会让它的 location 过期,下一次导航就会丢掉参数。
稳定链接。 用户默认按位置定位,因此收藏的 ?story=2.0 在信息流重新排序后会打开另一个人。改用稳定 id 定位用户 —— outerCodec 负责把 id 写进 URL,outerLocator 负 责找到它所在的位置。再传入 innerCodec 以及 innerLocate、innerIdentify,就能对分组内部的故事做同样的事;不传这三个,故事那一半仍然是分组内部的普通局部索引。
// ?story=alice.s3 — each axis addressed by a stable id, so a link survives
// the feed being reordered or paged.
import { urlStableIdKey } from '@reelkit/angular';
const key = urlIndexTwoAxisKey({
outerCodec: urlStableIdKey({ items: () => groups }).codec,
outerLocator: {
locate: (id) => groups.findIndex((group) => group.author.id === id),
identify: (index) => groups[index].author.id,
},
outerCount: () => groups.length,
innerCounts: () => groups.map((group) => group.stories.length),
innerCodec: urlStableIdKey({ items: () => [] }).codec,
innerLocate: (outer, id) =>
groups[outer].stories.findIndex((story) => story.id === id),
innerIdentify: (outer, index) => groups[outer].stories[index].id,
});无限信息流。 分页属于 outerLocator 的职责,与编解码器无关。locate 是同步的,只能回答已加载的分组 —— 指向第 400 个分组、而只加载了 20 个的分享链接会落空。locateAsync 是兜底,只在 locate 未命中时调用;故事随后会按最终落到的那个分组重新约束。
同一个 locateAsync, 外层轴
这与单轴 key 接受的 locateAsync 分页器是同一个 —— 在双轴 key 上它跟着你传入的 outerLocator 走,因此分页发生在用户这一轴,而故事仍是已解析分组内部的局部索引。
// locateAsync is asked only when locate misses — the group the link names is
// past what the feed has loaded. Page up to it, commit, and answer with its
// index; the story half is re-bounded against whichever group it settles on.
const key = urlIndexTwoAxisKey({
outerCount: () => this.groups().length,
innerCounts: () => this.groups().map((group) => group.stories.length),
outerLocator: {
locate: (index) => (index < this.groups().length ? index : null),
identify: (index) => index,
locateAsync: async (index) => {
const loaded = await this.loadUntilGroup(index);
if (!loaded) return null; // exhausted — the link names no group
this.groups.set(loaded); // commit — the player renders from this signal
return index;
},
},
});- 在
locateAsync等待期间,播放器保持关闭且参数原样保留,因此深层链接能熬过这次请求。返回null或拒绝则会移除参数。 - 如果答复在 URL 已经变化之后、关闭之后或卸载之后才到达,就会被丢弃 —— 一次缓慢的请求无法打开没人再要求的故事。
createOverlayUrlState的完整选项见 Angular API 参考,逐步讲解见 Angular 指南。
记住看过的内容
在一个分组被完整看完之前,圆环会显示渐变;分组重新打开时,会停在观看者尚未看过的第一条 Story。两者都来自同一个 createStoriesViewedStateController:在信息流所在的地方创建一次,再作为 viewed 交给圆环列表和播放器,它们会自行读取存储、跟随其变化并记录展示过的内容。存储的条目按 id 指明作者和 Story,因此信息流重新排序后依然有效,同一个控制器在 React 和 Vue 中也能使用。
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
RkStoriesOverlayComponent,
RkStoriesRingListComponent,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/angular-stories-player';
@Component({
selector: 'app-remembering-feed',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RkStoriesOverlayComponent, RkStoriesRingListComponent],
template: `
<rk-stories-ring-list [groups]="groups()" [viewed]="viewed" (selected)="open($event)" />
<rk-stories-overlay
[isOpen]="isOpen()"
[groups]="groups()"
[initialGroupIndex]="groupIndex()"
[viewed]="viewed"
(closed)="isOpen.set(false)"
/>
`,
})
export class RememberingFeedComponent {
protected readonly groups = signal<StoriesGroup[]>([]);
// One controller for both: the rings mute as a user is watched to the end,
// and the player opens each user on their first unseen story. It reads the
// groups through the signal, so users paged in later are counted too.
protected readonly viewed = createStoriesViewedStateController({
storageKey: 'my-app-stories-seen',
groups: () => this.groups(),
});
protected readonly isOpen = signal(false);
protected readonly groupIndex = signal(0);
protected open(index: number): void {
this.groupIndex.set(index);
this.isOpen.set(true);
}
}- 打开时的那条 Story 也算数。 播放器打开时,屏幕上已有的那条 Story 会被报告为已看,所以打开一个只有一条 Story 的分组再关闭,它就会被标记为看完。
- 往后滑同样会续播。
resumeStoryIndex对本次会话中第一次到达的每个分组生效,包括播放器打开时所在的那个,除非initialStoryIndex显式指定了 Story;已经滑过的分组会在离开处重新打开。显式传入的resumeStoryIndex输入优先于控制器的答案。 - 链接依然优先。 使用
rk-stories-url-overlay时,无论存储了什么,都由参数决定播 放器在哪里打开。其他情况下由续播回调决定。 - 计数是位置,不是累计。 条目记录的是到达过的最远那条 Story,因此给已看完的分组新增一条 Story 会让它的圆环重新亮起,从中间删掉一条则会让计数变短。
- 存储可插拔。 传入
storage: createSessionStorageAdapter()可在关闭标签页时遗忘,也可以传入你自己的StorageAdapter。两个打开的标签页会通过浏览器的 storage 事件保持同步。 - 只在打开时才创建的播放器读取得太晚。
rk-stories-overlay一存在就会读取存储,即使仍处于关闭状态,rk-stories-ring-list也是如此。如果把浮层包在@if (open)里,它就没有这个时机:请在宿主的构造函数中调用attachViewedState(() => this.viewed),它会在宿主存活期间一直保持关联。相关选项以及它所基于的底层构件见 Stories Core 页面。
桌面轮播
设置 desktopLayout="carousel",播放器就会像 Instagram 网页版查看器那样布局:当前 Story 以正常尺寸留在中间,每侧最多各有两个相邻分组,以更小、压暗的卡片呈现。点击卡片会打开对应分组,切换分组时卡片会随之滑过。传入与圆环列表相同的 viewed 控制器,已看完分组的卡片就会显示灰色圆环。
// 'carousel' lays the neighbouring users out as preview cards either side of
// the active story, the way Instagram does on the web. A phone always shows
// the active story alone, whatever this says.
@Component({
template: `
<rk-stories-overlay
[isOpen]="isOpen()"
[groups]="groups"
desktopLayout="carousel"
(closed)="isOpen.set(false)"
/>
`,
})
export class CarouselFeedComponent {}- 手机上仍是普通播放器。 宽度不超过 768px 时不渲染卡片,滑动使用
groupTransition,与默认的'single'布局相同。跨过这个宽度调整窗口大小会实时切换。卡片显示时,分组是滑动而不是转动,无论groupTransition如何设置。 - 每张卡片预览分组将要打开的位置:本次会话中离开时所在的 Story,否则是
resumeStoryIndex返回的 Story。卡片显示视频封面或图片。两者都没有的 Story(例如渐变背景上的文字 Story)会由你的rkStoriesSlide模板按播放器尺寸绘制后再缩小;没有封面的视频显示卡片背景。这个预览收到的是isActive: false,其生命周期回调什么也不做,因此无法驱动屏幕上那条 Story 的计时器。幻灯片在创建时做的事情(例如播放或统计)请放在isActive判断之后。 - 计时器会等幻灯片。 在卡片移动结束之前,Story 不会启动计时器,也不会为它触发
(storyViewed)。 - 每种切换分组的方式都会滑动:点击卡片、
StoriesApi.goToGroup、nextGroup和prevGroup,以及越过分组末尾时的方向键。触摸滑动本身就在移动播放器,所以不会再叠加一次滑动。 - 重新设计卡片样式用
rkStoriesGroupPreview插槽,见自定义轮播卡片。播放器仍然负责卡片的定位、缩放和滑动。
每个分组自带进度条和头部
默认情况下,播放器上方只有一个进度条和一个头部,分组切换完成后才换成新分组的内容。设置 chromePlacement="group" 后,每个分组都在自己的幻灯片里拥有一份,二者会像 Instagram 那样随分组一起转动。
// Every group carries its own progress bar and header, so both turn with the
// group instead of switching above the player once it has changed.
@Component({
template: `
<rk-stories-overlay
[isOpen]="isOpen()"
[groups]="groups"
chromePlacement="group"
(closed)="isOpen.set(false)"
/>
`,
})
export class GroupChromeFeedComponent {}- 相邻分组显示它停在哪里。 播放器转向它时,它的进度条和头部显示它将打开的那个 Story,尚未播放任何部分。
- 正在离开的分组保持原样。 它的进度条在转动结束前保留原有进度,所以一个已播完的分组会保持填满,而不会在转动中途清空。
- 屏幕上的每个分组都会渲染这些模板。
rkStoriesProgressBar和rkStoriesHeader的上下文带有groupIndex和isActive;对相邻分组,isActive为false,进度信号保持不变。 rkStoriesHeader模板位于滑动区域内。 点击它会切换 Story,除非点在button、链接或带role="button"的元素上,所以其中的每个控件都应是这几类元素之一。- 桌面端轮播在滑动过程中不受影响。 卡片滑动时播放器是隐藏的,因此这个选项在分组打开后才会显现。
模板插槽
八个 ng-template 指令可以替换播放器的任意部分。每个都会收到带类型的上下文,其中每个值都有名字,与 React 和 Vue 插槽使用的名字相同,所以在头部插槽里 let-author="author" 能拿到作者。主值同时也是隐式值,所以只写 let-author 效果一样。没有提供的插槽会保留内置的呈现。
| 指令 | 类 | 上下文 | 说明 |
|---|---|---|---|
rkStoriesError | RkStoriesErrorDirective | { $implicit: story, story, storyIndex, groupIndex } | 替换内置的 "Content unavailable" 面板,绘制在当前 Story 之上 |
rkStoriesFooter | RkStoriesFooterDirective | { $implicit: story, story, author, storyIndex } | 在当前 Story 下方加一个页脚;播放器本身不绘制页脚 |
rkStoriesGroupPreview | RkStoriesGroupPreviewDirective | { $implicit: group, group, groupIndex, story, offset, viewedCount, onOpen } | 填充桌面轮播的卡片内容;定位、缩放和滑动仍由播放器负责 |
rkStoriesHeader | RkStoriesHeaderDirective | { $implicit: author, author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose } | 替换默认头部(作者、暂停、声音、关闭)。使用 chromePlacement="group" 时它位于滑动区域内,参见每个分组自带进度条和头部。 |
rkStoriesLoading | RkStoriesLoadingDirective | { $implicit: story, story, storyIndex, groupIndex } | 替换当前 Story 加载时头部显示的默认加载指示 |
rkStoriesNavigation | RkStoriesNavigationDirective | { onPrevStory, onNextStory, onPrevGroup, onNextGroup, $implicit: { the same four } } | 同时替换桌面端的两个箭头 |
rkStoriesProgressBar | RkStoriesProgressBarDirective | { $implicit: group, group, totalStories, activeIndex, progress, groupIndex, isActive } | 替换 canvas 进度条。activeIndex 和 progress 是内核信号,请用 toAngularSignal 桥接。 |
rkStoriesSlide | RkStoriesSlideDirective | { $implicit: story, story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded } | 替换默认的图片与视频幻灯片 |
import {
RkStoriesOverlayComponent,
RkStoriesHeaderDirective,
RkStoriesFooterDirective,
} from '@reelkit/angular-stories-player';
@Component({
imports: [
RkStoriesOverlayComponent,
RkStoriesHeaderDirective,
RkStoriesFooterDirective,
],
template: `
<rk-stories-overlay [isOpen]="isOpen()" [groups]="groups" (closed)="isOpen.set(false)">
<!-- The author is the implicit value; everything else is named. -->
<ng-template rkStoriesHeader let-author let-onClose="onClose">
<header class="my-header">
<img [src]="author.avatar" [alt]="author.name" />
<strong>{{ author.name }}</strong>
<button type="button" (click)="onClose()">Close</button>
</header>
</ng-template>
<!-- The player draws no footer of its own; this adds one. -->
<ng-template rkStoriesFooter let-story let-author="author">
<footer class="my-footer">Reply to {{ author.name }}</footer>
</ng-template>
</rk-stories-overlay>
`,
})
export class SlottedComponent {}STORIES_TEMPLATE_SLOT_DIRECTIVES 把八个类导出为一个数组,方便一次导入多个的组件使用。
每个插槽都通过播放器自身的注入器渲染,因此插槽里绘制的组件能解析到播放器的 provider——幻灯片模板中的 rk-video-story-slide 拿到的,正是头部按钮写入的那份声音状态。
自定义幻灯片
import {
RkStoriesSlideDirective,
RkImageStorySlideComponent,
RkVideoStorySlideComponent,
} from '@reelkit/angular-stories-player';
// A slide template is projected content, so it cannot pick up the player's
// story type on its own — cast to your own feed's type where you know it.
@Component({
template: `
<rk-stories-overlay [isOpen]="isOpen()" [groups]="groups" (closed)="isOpen.set(false)">
<ng-template
rkStoriesSlide
let-story
let-groupIndex="groupIndex"
let-index="index"
let-activeGroupIndex="activeGroupIndex"
let-activeStoryIndex="activeStoryIndex"
let-onReady="onReady"
let-onError="onError"
let-onDurationReady="onDurationReady"
>
@if (story.mediaType === 'video') {
<!-- Every video plays through one shared element, which this slide
claims while both indexes point at it. -->
<rk-video-story-slide
[src]="story.src"
[poster]="story.poster"
[groupIndex]="groupIndex"
[storyIndex]="index"
[activeGroupIndex]="activeGroupIndex"
[activeStoryIndex]="activeStoryIndex"
(durationReady)="onDurationReady($event)"
(playbackStarted)="onReady()"
(failed)="onError()"
/>
} @else {
<rk-image-story-slide
[src]="story.src"
(loaded)="onReady()"
(failed)="onError()"
/>
}
</ng-template>
</rk-stories-overlay>
`,
})
export class CustomSlideComponent {}自定义幻灯片通过 rkStoriesSlide 上下文中的回调告诉播放器内容的状态。播放器只处理屏幕上那条 Story 的回调;其他幻灯片的调用会被忽略。
| 回调 | 时机 |
|---|---|
onReady | 内容已就绪(图片加载完成、视频开始播放)。进度计时器开始,或等待轮播滑动结束后再开始。 |
onWaiting | 内容卡住(视频播放中途缓冲)。显示加载圈,计时器暂停。 |
onError | 内容加载失败。显示错误面板,计时器暂停。 |
onDurationReady | 以毫秒报告媒体的真实时长(例如从视频元数据中读取) ,让计时器与之匹配。Story 上设置的 duration 优先于它。 |
onEnded | 媒体自行播放结束,例如视频播到了结尾。播放器不等计时器,直接进入下一条 Story。 |
rk-image-story-slide 会发出 (loaded) 和 (failed),rk-video-story-slide 会发出 (playbackStarted)、(buffering)、(failed)、(durationReady) 和 (finished),因此包裹它们的幻灯片模板只需把每个事件转交给对应的回调。
预加载缓存
内置的 RkImageStorySlideComponent 和 RkVideoStorySlideComponent 会在后台预加载下一条 Story。观众翻到已预加载的 Story 时,内容立即出现,不会有加载指示。失败过一次的 URL 也会被记住,所以重新打开损坏的 Story 会直接显示错误,而不是再试一次。
自定义导航
// The four moves arrive as the implicit value, so `let-nav` is all a
// template needs. Supplying this replaces both built-in arrows.
<ng-template rkStoriesNavigation let-nav>
<button type="button" (click)="nav.onPrevGroup()">Previous user</button>
<button type="button" (click)="nav.onPrevStory()">Back</button>
<button type="button" (click)="nav.onNextStory()">Next</button>
<button type="button" (click)="nav.onNextGroup()">Next user</button>
</ng-template>自定义进度条
// activeIndex and progress are core signals, the same ones the built-in
// canvas bar reads. Bridge them with toAngularSignal to follow the timer.
<ng-template
rkStoriesProgressBar
let-group
let-total="totalStories"
let-activeIndex="activeIndex"
let-progress="progress"
>
<app-my-progress-bar
[totalStories]="total"
[activeIndex]="activeIndex"
[progress]="progress"
/>
</ng-template>自定义加载与错误
// Both are scoped to the story on screen: the player reports one loading
// state, and these draw only over the slide the viewer is looking at.
<ng-template rkStoriesLoading let-story>
<div class="my-spinner">Loading {{ story.mediaType }}…</div>
</ng-template>
<ng-template rkStoriesError let-story>
<div class="my-error">That story could not be loaded.</div>
</ng-template>自定义轮播卡片
// One side card of the desktop carousel. `offset` is negative on the left
// and positive on the right, and 0 while the card slides through the centre.
<ng-template
rkStoriesGroupPreview
let-group
let-story="story"
let-offset="offset"
let-viewedCount="viewedCount"
let-onOpen="onOpen"
>
<button type="button" class="my-card" (click)="onOpen()">
<img [src]="group.author.avatar" [alt]="group.author.name" />
<span>{{ group.author.name }}</span>
<span>{{ viewedCount }} / {{ group.stories.length }} seen</span>
</button>
</ng-template>转场
groupTransition 控制切换作者时的 3D 效果。转场函数从 @reelkit/angular 导入:
import { cubeTransition, flipTransition, fadeTransition } from '@reelkit/angular';
@Component({
template: `
<!-- Between users only. Stories inside one user always cross-fade. -->
<rk-stories-overlay
[isOpen]="isOpen()"
[groups]="groups"
[groupTransition]="flip"
[innerTransitionDuration]="200"
(closed)="isOpen.set(false)"
/>
`,
})
export class TransitionsComponent {
protected readonly flip = flipTransition;
}双击与点赞
双击会播放爱心动画,给出即时的视觉反馈。(doubleTapped) 会带上分组与 Story 的索引,方便你把点赞保存到自己的状态里。播放器本身不保存任何点赞状态。
// A double tap plays the heart over the story and reports which story it
// landed on. The heart draws itself; this is only the reporting half.
<rk-stories-overlay
[isOpen]="isOpen()"
[groups]="groups"
(doubleTapped)="like($event.groupIndex, $event.storyIndex)"
(closed)="isOpen.set(false)"
/>自定义爱心动画
用 --rk-stories-heart-duration 令牌调整速度(见主题定制)。要改颜色、大小,或者完全隐藏爱心,请针对 .rk-stories-heart 类。RkHeartAnimationComponent 也单独导出,可独立使用。
: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 隐藏它并从 (doubleTapped) 触发你自己的动画。如果你需要爱心插槽,请到 GitHub Issues 告诉我们。
StoriesApi
需要命令式控制时,从 (apiReady) 取得 API。播放器每次打开都会重建,所以上一次打开时保存的句柄已经过期——请使用最新的那个。
import type { StoriesApi } from '@reelkit/angular-stories-player';
@Component({
template: `
<rk-stories-overlay
[isOpen]="isOpen()"
[groups]="groups"
(apiReady)="api = $event"
(closed)="isOpen.set(false)"
/>
<button type="button" (click)="api?.nextGroup()">Skip this user</button>
`,
})
export class DrivenComponent {
// Handed over once the player is ready to take orders, and again on every
// open: the player is rebuilt each time, so keep the newest one.
protected api: StoriesApi | null = null;
}方法
| 方法 | 类型 | 说明 |
|---|---|---|
nextStory() | () => void | 在当前分组内前进到下一条 Story |
prevStory() | () => void | 在当前分组内回到上一条 Story |
nextGroup() | () => void | 切换到下一位作者的分组 |
prevGroup() | () => void | 切换到上一位作者的分组 |
goToGroup(index) | (index: number) => void | 按索引跳转到指定分组 |
pause() | () => void | 暂停自动翻页和进度计时器 |
resume() | () => void | 恢复自动翻页和进度计时器 |
子组件
导出的构件,可以在插槽内组合,也可以单独使用。
| 组件 | 是什么 |
|---|---|
RkCanvasProgressBarComponent | 直接由计时器信号绘制的 canvas 分段进度条。分组内 Story 很多时会处理滑动窗口。传入 [live]="false" 时只在信号变化和尺寸改变后重绘,没有动画循环。尺寸和颜色是独立的 input:gap、barHeight、minSegmentWidth、bgColor、fillColor。 |
RkHeartAnimationComponent | 双击的爱心。动画结束时发出 (completed),父组件据此移除它。 |
RkImageStorySlideComponent | 使用 object-fit: cover 的整屏图片幻灯片,通过 output 上报加载与错误。 |
RkStoriesRingComponent | 带渐变圆环的圆形头像。两种状态:还有未看内容时旋转,全部看完后变为平直。发出 (clicked)。 |
RkStoriesRingListComponent | 带作者名的横向圆环列表,每个分组一个。发出 (selected) 并带上分组索引。 |
RkStoryHeaderComponent | 默认头部:头像、名称、认证标识、相对时间、暂停、声音、关闭以及加载指示。暂停和声音按钮只有设置了 [showPauseButton] 和 [showSoundButton] 才会出现,播放器为自己的头部就是这样设置的。 |
RkVideoStorySlideComponent | 基于唯一共享 <video> 元素的视频幻灯片,用于在 iOS 上保持声音连续。处理自动播放、封面帧与时长上报。 |
RkStoriesCarouselComponent 也随 CarouselSlide 类型一起导出,但它绘制所依赖的值都归浮层所有——想改卡片样式,请使用 rkStoriesGroupPreview 插槽,而不是自己挂载轮播。
import {
RkStoriesRingComponent,
RkStoriesRingListComponent,
RkStoryHeaderComponent,
RkCanvasProgressBarComponent,
RkImageStorySlideComponent,
RkVideoStorySlideComponent,
RkHeartAnimationComponent,
} from '@reelkit/angular-stories-player';
// Each piece the player draws is exported on its own, so a page can build a
// ring row without the overlay, or a slot can rebuild a region out of the
// same parts the player uses.
@Component({
imports: [RkStoriesRingComponent],
template: `
<rk-stories-ring
[author]="author"
[totalStories]="5"
[viewedCount]="2"
[size]="68"
(clicked)="open()"
/>
`,
})
export class RingOnlyComponent {}自定义 Story 类型
用自己的字段扩展 StoryItem,并用它给分组标注类型:
// The player is generic over the story type, so a feed can carry whatever
// extra fields a slide template needs.
interface PromoStory extends StoryItem {
title?: string;
ctaText?: string;
}
const groups: StoriesGroup<PromoStory>[] = [
{
author: { id: 'shop', name: 'Shop', avatar: '/shop.jpg' },
stories: [
{ id: 'p1', mediaType: 'image', src: '/promo.jpg', title: 'Flash Sale', ctaText: 'Shop now' },
],
},
];重新导出
使用者需要的一切都从本包导出,因此永远不必直接从 @reelkit/core 导入。
| 来源 | 重新导出 |
|---|---|
@reelkit/angular | SoundStateService、createOverlayUrlState、urlIndexTwoAxisKey、urlStableIdTwoAxisKey、base64UrlCodec、createViewedStateController、twoAxisViewedTracking、createLocalStorageAdapter、createSessionStorageAdapter、createMemoryStorageAdapter,以及类型 OverlayUrlStateOptions、UrlAdapter、UrlCodec、UrlLocator、UrlKey、UrlStateController、TwoAxisPosition、TwoAxisIdentity、UrlIndexTwoAxisKeyOptions、ViewedStateController、ViewedStateOptions、StorageAdapter |
@reelkit/stories-core | createStoriesViewedStateController、StoriesViewedStateController、StoriesViewedStateControllerConfig,以及内容类型 StoryItem、AuthorInfo、StoriesGroup、MediaType |
attachViewedState 同样导出,供自己持有已看状态控制器的组件使用,让它在该组件存活期间保持连接。
RkStoriesOverlayComponent 输入
rk-stories-overlay
| 输入 | 类型 | 默认值 | 说明 |
|---|---|---|---|
ariaLabel | string | 'Stories player' | 对话框区域的无障碍名称;浮层打开时由屏幕阅读器朗读 |
chromePlacement | 'overlay' | 'group' | 'overlay' | 进度条和头部放在哪里。'overlay' 在播放器上方绘制一份;'group' 在每个分组的幻灯片里各绘制一份,二者随分组一起转动。该类型以 ChromePlacement 导出。 |
defaultImageDuration | number | 5000 | 图片 Story 的自动翻页时长,单位毫秒 |
desktopLayout | 'single' | 'carousel' | 'single' | 桌面布局。'carousel' 会把相邻分组作为预览卡片排在当前 Story 两侧,并在分组之间滑动。手机上始 终只显示当前 Story。该类型以 DesktopLayout 导出。 |
enableKeyboard | boolean | true | 启用键盘操作(左右方向键、Escape) |
groups | StoriesGroup<T>[] | required | 要展示的 Story 分组。播放器打开期间新增的分组或 Story 会被接纳,且不会丢失观众当前的位置。 |
groupTransition | TransitionTransformFn | cubeTransition | 作者之间的转场。桌面轮播显示时会被忽略,改为滑动。 |
hideUIOnPause | boolean | true | 长按暂停期间隐藏进度条和头部 |
initialGroupIndex | number | 0 | 播放器打开时所在分组的索引(从 0 开始) |
initialStoryIndex | number | 续播位置,否则 0 | 播放器打开时所在 Story 的索引(从 0 开始)。显式指定会覆盖任何记住的位置;不指定时,首个分组与其他分组一样按续播位置打开。 |
innerTransitionDuration | number | 200 | 同一位作者的 Story 之间淡入淡出的时长,单位毫秒 |
isOpen | boolean | required | 为 true 时渲染播放器并锁定 body 滚动 |
minSegmentWidth | number | 8 | 进度分段的最小宽度,低于此值进度条会改为滚动一个分段窗口,单位像素 |
resumeStoryIndex | (groupIndex: number) => number | — | 首次到达某个分组时从哪条 Story 打开。只对本次打开期间尚未访问过的分组询问,且优先于 viewed。 |
tapZoneSplit | number | 0.3 | 点击区域的分割比例(0–1)。左侧后退,其余前进。 |
viewed | StoriesViewedStateController | — | 观众已看过的内容,来自 createStoriesViewedStateController()。分组从第一条未看的 Story 续播,每条展示过的 Story 都会记录,看完的分组在轮播卡片上圆环会变暗。请把同一个控制器也交给 rk-stories-ring-list。 |
每个插槽还有对应的输入,供自己持有 TemplateRef 而非投影它的组件使用:slideTemplate、headerTemplate、footerTemplate、progressBarTemplate、navigationTemplate、loadingTemplate、errorTemplate 和 groupPreviewTemplate。以这种方式指定的模板优先于投影进播放器的模板。
RkStoriesOverlayComponent 输出
| 输出 | 负载 | 说明 |
|---|---|---|
(apiReady) | StoriesApi | 播放器可以接受指令时发出,此后每次打开都会再次发出 |
(closed) | void | 观众关闭播放器时发出。播放器本身不动,由宿主决定。在 rk-stories-url-overlay 中,此时参数已被清除。 |
(doubleTapped) | { groupIndex, storyIndex } | 双击时发出,同时播放爱心动画 |
(groupChanged) | number | 当前分组改变后发出 |
(paused) | void | 长按或点击头部暂停按钮时发出 |
(resumed) | void | 松开或点击头部播放按钮时发出 |
(storyChanged) | { groupIndex, storyIndex } | 当前 Story 改变后发出 |
(storyCompleted) | { groupIndex, storyIndex } | 某条 Story 的计时器走完时发出 |
(storyViewed) | { groupIndex, storyIndex } | Story 出现在屏幕上时发出,包括打开时的第一条 |
播放器只上报关闭而不自行关闭,因此 (closed) 覆盖了所有退出路径:✕ 按钮、向下滑动、Escape,以及最后一个分组的最后一条 Story 播放完毕。
RkStoriesUrlOverlayComponent 输入
rk-stories-url-overlay
接受上面的所有输入,但开合状态三件套——isOpen、initialGroupIndex、initialStoryIndex——改由控制器提供。
| 输入 | 类型 | 默认值 | 说明 |
|---|---|---|---|
controller | UrlStateController<TwoAxisPosition> | required | 由 createOverlayUrlState 配合 urlIndexTwoAxisKey 展开得到的控制器。它的 position(一个 { outer, inner } 对象)决定播放器是否打开以及从哪里打开;浮层会在每次导航和关闭时写回。 |
StoryItem、AuthorInfo 与 StoriesGroup 接口
import type {
StoryItem,
AuthorInfo,
StoriesGroup,
StoriesApi,
DesktopLayout,
} from '@reelkit/angular-stories-player';
interface StoryItem {
id: string;
mediaType: 'image' | 'video';
src: string;
poster?: string;
duration?: number;
aspectRatio?: number;
createdAt?: string | Date;
}
interface AuthorInfo {
id: string;
name: string;
avatar: string;
verified?: boolean;
}
interface StoriesGroup<T extends StoryItem = StoryItem> {
author: AuthorInfo;
stories: T[];
}模板插槽上下文类型
所有上下文类型都已导出,便于在编译器无法推断的地方为插槽标注类型——StoriesSlideContext、StoriesHeaderContext、StoriesFooterContext、StoriesProgressBarContext、StoriesNavigationContext、StoriesLoadingContext、StoriesErrorContext 和 StoriesGroupPreviewContext,以及 StoriesNavigationActions、DesktopLayout、ChromePlacement 和 StoriesApi:
// Every slot context in one place. Every value is named, by the names the
// react and vue slots use; the main one is also what `let-x` with no name
// binds.
import type {
StoriesSlideContext,
StoriesHeaderContext,
StoriesFooterContext,
StoriesProgressBarContext,
StoriesNavigationContext,
StoriesGroupPreviewContext,
StoriesLoadingContext,
StoriesErrorContext,
} from '@reelkit/angular-stories-player';CSS 类名
类名是普通名称(没有作用域),因此在 @reelkit/angular-stories-player/styles.css 之后加载的样式表可以覆盖其中任何一个。它们与 @reelkit/react-stories-player、@reelkit/vue-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 | 圆角的 Story 画布(position、overflow) |
.rk-stories-ui-layer | Overlay | 播放器之上的单层界面(头部、进度、爱心)。使用 chromePlacement="group" 时改为每个分组的幻灯片内各一层(头部、进度),爱心直接位于播放器中 |
.rk-stories-ui-layer--hidden | Overlay | 界面隐藏状态(由 hideUIOnPause 切换) |
.rk-stories-error | Overlay | 错误状态(居中的图标与文字) |
.rk-stories-error-text | Overlay | 错误信息文字 |
.rk-stories-nav-btn | Overlay | 桌面端的上一个/下一个箭头 |
.rk-stories-progress-bar | ProgressBar | canvas 进度条的定位包装层 |
.rk-stories-slide-wrapper | Overlay | 一个分组的 Story(外层幻灯片) |
.rk-stories-story | Overlay | 单条 Story(内层幻灯片根元素) |
.rk-stories-image | ImageStorySlide | 整屏图片(object-fit: cover) |
.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-header | StoryHeader | 头部一行(头像、名称、时间、控件) |
.rk-stories-header--hidden | StoryHeader | 头部隐藏状态(visible 输入设为 false) |
.rk-stories-header-avatar | StoryHeader | 作者头像图片 |
.rk-stories-header-name | StoryHeader | 作者名称文字 |
.rk-stories-header-verified | StoryHeader | 认证徽标容器 |
.rk-stories-header-time | StoryHeader | 相对时间文字 |
.rk-stories-header-actions | StoryHeader | 右侧控件(声音、暂停、关闭) |
.rk-stories-header-btn | StoryHeader | 暂停、声音和关闭按钮 |
.rk-stories-header-btn--desktop | StoryHeader | 暂停按钮,仅在宽度超过 768px 时显示 |
.rk-stories-header-spinner | StoryHeader | 当前 Story 加载时的加载指示 |
.rk-stories-ring | StoriesRing | 带渐变圆环的圆形头像 |
.rk-stories-ring--active | StoriesRing | 仍有未看 Story 的圆环(渐变 旋转) |
.rk-stories-ring-avatar | StoriesRing | 圆环内的头像图片 |
.rk-stories-ring-list | StoriesRingList | 横向排列的圆环列表 |
.rk-stories-ring-list-item | StoriesRingList | 圆环 + 名称的纵列 |
.rk-stories-ring-list-name | StoriesRingList | 每个圆环下方的作者名称 |
.rk-stories-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 | 作为卡片预览缩小绘制的幻灯片模板 |
.rk-stories-card-scrim | Carousel | 预览上方的压暗层 |
.rk-stories-card-info | Carousel | 圆环、作者名称和时间的纵列 |
.rk-stories-card-name | Carousel | 作者名称 |
.rk-stories-card-time | Carousel | 相对时间文字 |
主题定制
每一个颜色、尺寸、时长和 z-index 都是 :root 上的 --rk-stories-* 自定义属性,因此换肤只需一份样式表,而不必改动组件。这些令牌与 React、Vue 播放器共用。
| 令牌 | 默认值 | 控制内容 |
|---|---|---|
--rk-stories-overlay-bg | #000 | 全屏背景颜色 |
--rk-stories-overlay-z | 9999 | 浮层 z-index |
--rk-stories-swipe-gap | 16px | 导航按钮与 Story 画布之间的间距 |
--rk-stories-container-radius | 12px | Story 画布的圆角(桌面端) |
--rk-stories-container-radius-mobile | 0 | 手机上 Story 画布的圆角 |
--rk-stories-ui-z | 15 | 界面层(头部、进度条)的 z-index |
--rk-stories-ui-transition | 200ms | hideUIOnPause 切换时的淡入淡出时长 |
--rk-stories-top-shade-height | 120px | 头部后方顶部渐变遮罩的高度 |
--rk-stories-top-shade-bg | linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%) | 顶部渐变遮罩颜色 |
--rk-stories-error-z | 5 | 错误面板的 z-index |
--rk-stories-error-bg | linear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%) | 错误状态背景渐变 |
--rk-stories-error-fg | rgba(255, 255, 255, 0.5) | 错误图标和文字颜色 |
--rk-stories-error-gap | 12px | 错误图标与文字之间的间距 |
--rk-stories-error-text-size | 13px | 错误信息字号 |
--rk-stories-nav-z | 20 | 桌面端导航按钮的 z-index |
--rk-stories-nav-size | 44px | 桌面端上一个/下一个按钮尺寸 |
--rk-stories-nav-bg | rgba(255, 255, 255, 0.1) | 桌面端导航按钮背景 |
--rk-stories-nav-bg-hover | rgba(255, 255, 255, 0.2) | 桌面端导航按钮悬停背景 |
--rk-stories-nav-fg | rgba(255, 255, 255, 0.7) | 桌面端导航按钮图标颜色 |
--rk-stories-nav-fg-hover | #fff | 桌面端导航按钮悬停图标颜色 |
--rk-stories-nav-transition | 150ms | 桌面端导航按钮悬停的过渡时长 |
--rk-stories-video-bg | #000 | <video> 后方的留黑背景 |
--rk-stories-video-poster-transition | 200ms | 视频开始播放时封面淡出的时长 |
--rk-stories-progress-bar-z | 10 | 进度条的 z-index |
--rk-stories-progress-bar-padding | 8px 8px 0 | 进度条周围的留白 |
--rk-stories-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 | 轮播滑动时长 |
--rk-stories-ring-spin-duration | 4s | 仍有未看 Story 的圆环的旋转时长 |
--rk-stories-ring-active-scale | 0.95 | 圆环按下时的缩放比例 |
--rk-stories-ring-active-gradient | conic-gradient(from 180deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888, #f09433) | 还有未看快拍时圆环显示的内容,可以是任意 CSS 图像或颜色。浅色页面请把它设在 .rk-stories-ring-list 上而不是 :root,这样播放器的轮播会保留自己的颜色 |
--rk-stories-ring-viewed-color | rgba(255, 255, 255, 0.25) | 全部看完的圆环显示的内容,默认值适合深色页面。浅色页面请把它设在 .rk-stories-ring-list 上而不是 :root |
--rk-stories-ring-gradient | none | 内部使用:圆环每次渲染都会写入它,并指向上面两个令牌,除非给圆环传了 gradientColors 或 viewedColor |
--rk-stories-ring-list-gap | 12px | 列表中圆环之间的间距 |
--rk-stories-ring-list-padding | 12px | 圆环列表的内边距 |
--rk-stories-ring-list-item-gap | 4px | 圆环与其名称之间的间距 |
--rk-stories-ring-list-name-size | 12px | 每个圆环下方作者名称的字号 |
--rk-stories-header-z | 5 | 头部的 z-index |
--rk-stories-header-top | 18px | 头部距 Story 顶部的垂直偏移 |
--rk-stories-header-padding | 12px 16px | 头部行的内边距 |
--rk-stories-header-gap | 8px | 头部行各项之间的间距 |
--rk-stories-header-transition | 200ms | 头部隐藏时的淡出时长 |
--rk-stories-header-avatar-size | 32px | 头像宽高 |
--rk-stories-header-name-fg | #fff | 作者名称颜色 |
--rk-stories-header-name-size | 14px | 作者名称字号 |
--rk-stories-header-name-weight | 600 | 作者名称字重 |
--rk-stories-header-time-fg | rgba(255, 255, 255, 0.6) | 相对时间文字颜色 |
--rk-stories-header-time-size | 12px | 相对时间字号 |
--rk-stories-header-actions-gap | 8px | 头部按钮之间的间距 |
--rk-stories-header-btn-fg | #fff | 头部操作图标颜色(关闭、声音、暂停) |
--rk-stories-header-btn-padding | 4px | 头部按钮的内边距 |
--rk-stories-header-spinner-size | 20px | 加载 指示的尺寸 |
--rk-stories-header-spinner-track | rgba(255, 255, 255, 0.3) | 加载指示的轨道颜色 |
--rk-stories-header-spinner-fg | #fff | 加载指示的弧线颜色 |
--rk-stories-header-spinner-duration | 0.8s | 加载指示转一圈的时长 |
--rk-stories-heart-z | 20 | 双击爱心的 z-index |
--rk-stories-heart-duration | 800ms | 弹出/淡出动画时长 |
把下面这段放进一个在 @reelkit/angular-stories-player/styles.css 之后加载的样式表。
/* Loaded after @reelkit/angular-stories-player/styles.css. Every token has a
default on :root, so override only what you want to change. The names
match the React and Vue players, 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")。设置 ariaLabel 可以改变屏幕阅读器的朗读内容,默认是 "Stories player"。
浮层在打开时接管焦点,关闭时把焦点还给触发它的元素。Tab 与 Shift+Tab 在内部可聚焦元素之间循环,并在两端回绕;跑出去的焦点(点击外部、程序化聚焦)会被拉回来。播放器打开期间 body 滚动保持锁定。实现使用 @reelkit/core 的 captureFocusForReturn 与 createFocusTrap,并由 @reelkit/angular 重新导出。
在桌面轮播中,每张默认卡片都是一个按钮,标签为 "Open stories by" 加作者名。卡片在 Tab 顺序中排在播放器控件之后,并在滑动进行时移出 Tab 顺序;打开自己分组的那张卡片会把焦点交还给对话框,而不是丢在页面上。
服务端渲染
播放器在打开之前不渲染任何内容,构造期间也不读取任何浏览器 API,因此在 Angular SSR 下是安全的。已看状态在挂载之后读取,而不是在渲染过程中读取,这正是服务端渲染与首次客户端渲染保持一致的原因。
键盘快捷键
| 按键 | 操作 |
|---|---|
ArrowLeft | 上一条 Story。在分组的第一条 Story 上时,打开上一个分组,停在离开时的位置;在第一个分组上什么也不做。 |
ArrowRight | 下一条 Story。在分组的最后一条 Story 上时,打开下一个分组;在最后一个分组上则关闭播放器。 |
Escape | 关闭播放器 |