Angular Stories Player

面向 Angular 的 Instagram 风格 Stories 播放浮层,使用 @reelkit/angular-stories-player。它与 React、Vue 播放器共用 @reelkit/stories-core 引擎,因此行为、CSS 类名和主题令牌完全一致。

查看在线演示 →

特性

嵌套导航
点击翻动 Story,滑动切换作者
视频 Story
自动播放并可切换声音
自动翻页
每条 Story 的时长可配置
3D 转场
立方体、翻转、淡入淡出、缩放、滑动
进度条
基于 canvas 的分段进度
图片与视频
两种媒体类型都支持
模板插槽
八个 ng-template 区域
双击点赞
双击播放爱心动画
桌面导航
大屏上的左右箭头按钮
Story 圆环
Instagram 风格的头像圆环
泛型类型
用自定义字段扩展 StoryItem
主题令牌
仅靠 --rk-stories-* 换肤
URL 状态
可分享的 ?story=group.story 链接
已看状态
已看圆环与续播位置在刷新后仍然保留

安装

bash
npm install @reelkit/angular-stories-player @reelkit/angular lucide-angular

样式表只需注册一次:

typescript
// 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。

typescript
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 传入。

内置 key

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

typescript
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,就能对分组内部的故事做同样的事;不传这三个,故事那一半仍然是分组内部的普通局部索引。

typescript
// ?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 走,因此分页发生在用户这一轴,而故事仍是已解析分组内部的局部索引。

typescript
// 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 中也能使用。

typescript
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 控制器,已看完分组的卡片就会显示灰色圆环。

typescript
// '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 那样随分组一起转动。

typescript
// 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 效果一样。没有提供的插槽会保留内置的呈现。

指令类上下文说明
rkStoriesErrorRkStoriesErrorDirective{ $implicit: story, story, storyIndex, groupIndex }替换内置的 "Content unavailable" 面板,绘制在当前 Story 之上
rkStoriesFooterRkStoriesFooterDirective{ $implicit: story, story, author, storyIndex }在当前 Story 下方加一个页脚;播放器本身不绘制页脚
rkStoriesGroupPreviewRkStoriesGroupPreviewDirective{ $implicit: group, group, groupIndex, story, offset, viewedCount, onOpen }填充桌面轮播的卡片内容;定位、缩放和滑动仍由播放器负责
rkStoriesHeaderRkStoriesHeaderDirective{ $implicit: author, author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose }替换默认头部(作者、暂停、声音、关闭)。使用 chromePlacement="group" 时它位于滑动区域内,参见每个分组自带进度条和头部。
rkStoriesLoadingRkStoriesLoadingDirective{ $implicit: story, story, storyIndex, groupIndex }替换当前 Story 加载时头部显示的默认加载指示
rkStoriesNavigationRkStoriesNavigationDirective{ onPrevStory, onNextStory, onPrevGroup, onNextGroup, $implicit: { the same four } }同时替换桌面端的两个箭头
rkStoriesProgressBarRkStoriesProgressBarDirective{ $implicit: group, group, totalStories, activeIndex, progress, groupIndex, isActive }替换 canvas 进度条。activeIndex 和 progress 是内核信号,请用 toAngularSignal 桥接。
rkStoriesSlideRkStoriesSlideDirective{ $implicit: story, story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }替换默认的图片与视频幻灯片
typescript
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 拿到的,正是头部按钮写入的那份声音状态。

自定义幻灯片

typescript
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 会直接显示错误,而不是再试一次。

自定义导航

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

自定义进度条

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

自定义加载与错误

typescript
// 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>
typescript
// 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 导入:

typescript
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 的索引,方便你把点赞保存到自己的状态里。播放器本身不保存任何点赞状态。

typescript
// 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 也单独导出,可独立使用。

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 隐藏它并从 (doubleTapped) 触发你自己的动画。如果你需要爱心插槽,请到 GitHub Issues 告诉我们。

StoriesApi

需要命令式控制时,从 (apiReady) 取得 API。播放器每次打开都会重建,所以上一次打开时保存的句柄已经过期——请使用最新的那个。

typescript
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 插槽,而不是自己挂载轮播。

typescript
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,并用它给分组标注类型:

typescript
// 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/angularSoundStateService、createOverlayUrlState、urlIndexTwoAxisKey、urlStableIdTwoAxisKey、base64UrlCodec、createViewedStateController、twoAxisViewedTracking、createLocalStorageAdapter、createSessionStorageAdapter、createMemoryStorageAdapter,以及类型 OverlayUrlStateOptions、UrlAdapter、UrlCodec、UrlLocator、UrlKey、UrlStateController、TwoAxisPosition、TwoAxisIdentity、UrlIndexTwoAxisKeyOptions、ViewedStateController、ViewedStateOptions、StorageAdapter
@reelkit/stories-corecreateStoriesViewedStateController、StoriesViewedStateController、StoriesViewedStateControllerConfig,以及内容类型 StoryItem、AuthorInfo、StoriesGroup、MediaType

attachViewedState 同样导出,供自己持有已看状态控制器的组件使用,让它在该组件存活期间保持连接。

RkStoriesOverlayComponent 输入

rk-stories-overlay

输入类型默认值说明
ariaLabelstring'Stories player'对话框区域的无障碍名称;浮层打开时由屏幕阅读器朗读
chromePlacement'overlay' | 'group''overlay'进度条和头部放在哪里。'overlay' 在播放器上方绘制一份;'group' 在每个分组的幻灯片里各绘制一份,二者随分组一起转动。该类型以 ChromePlacement 导出。
defaultImageDurationnumber5000图片 Story 的自动翻页时长,单位毫秒
desktopLayout'single' | 'carousel''single'桌面布局。'carousel' 会把相邻分组作为预览卡片排在当前 Story 两侧,并在分组之间滑动。手机上始终只显示当前 Story。该类型以 DesktopLayout 导出。
enableKeyboardbooleantrue启用键盘操作(左右方向键、Escape)
groupsStoriesGroup<T>[]required要展示的 Story 分组。播放器打开期间新增的分组或 Story 会被接纳,且不会丢失观众当前的位置。
groupTransitionTransitionTransformFncubeTransition作者之间的转场。桌面轮播显示时会被忽略,改为滑动。
hideUIOnPausebooleantrue长按暂停期间隐藏进度条和头部
initialGroupIndexnumber0播放器打开时所在分组的索引(从 0 开始)
initialStoryIndexnumber续播位置,否则 0播放器打开时所在 Story 的索引(从 0 开始)。显式指定会覆盖任何记住的位置;不指定时,首个分组与其他分组一样按续播位置打开。
innerTransitionDurationnumber200同一位作者的 Story 之间淡入淡出的时长,单位毫秒
isOpenbooleanrequired为 true 时渲染播放器并锁定 body 滚动
minSegmentWidthnumber8进度分段的最小宽度,低于此值进度条会改为滚动一个分段窗口,单位像素
resumeStoryIndex(groupIndex: number) => number—首次到达某个分组时从哪条 Story 打开。只对本次打开期间尚未访问过的分组询问,且优先于 viewed。
tapZoneSplitnumber0.3点击区域的分割比例(0–1)。左侧后退,其余前进。
viewedStoriesViewedStateController—观众已看过的内容,来自 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——改由控制器提供。

输入类型默认值说明
controllerUrlStateController<TwoAxisPosition>required由 createOverlayUrlState 配合 urlIndexTwoAxisKey 展开得到的控制器。它的 position(一个 { outer, inner } 对象)决定播放器是否打开以及从哪里打开;浮层会在每次导航和关闭时写回。

StoryItem、AuthorInfo 与 StoriesGroup 接口

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

typescript
// 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-overlayOverlay覆盖全屏的固定背景(颜色、z-index)
.rk-stories-overlay--carouselOverlay正在显示桌面轮播的浮层
.rk-stories-overlay--slidingOverlay轮播滑动进行中;播放器画布被隐藏
.rk-stories-swipe-wrapperOverlay下滑关闭的包装层(内含箭头与画布)
.rk-stories-containerOverlay圆角的 Story 画布(position、overflow)
.rk-stories-ui-layerOverlay播放器之上的单层界面(头部、进度、爱心)。使用 chromePlacement="group" 时改为每个分组的幻灯片内各一层(头部、进度),爱心直接位于播放器中
.rk-stories-ui-layer--hiddenOverlay界面隐藏状态(由 hideUIOnPause 切换)
.rk-stories-errorOverlay错误状态(居中的图标与文字)
.rk-stories-error-textOverlay错误信息文字
.rk-stories-nav-btnOverlay桌面端的上一个/下一个箭头
.rk-stories-progress-barProgressBarcanvas 进度条的定位包装层
.rk-stories-slide-wrapperOverlay一个分组的 Story(外层幻灯片)
.rk-stories-storyOverlay单条 Story(内层幻灯片根元素)
.rk-stories-imageImageStorySlide整屏图片(object-fit: cover)
.rk-stories-videoVideoStorySlide视频幻灯片容器
.rk-stories-video-elementVideoStorySlide共享的 <video> 元素
.rk-stories-video-posterVideoStorySlide封面帧,播放开始前显示
.rk-stories-video-poster--visibleVideoStorySlide封面可见状态(播放开始前)
.rk-stories-heartHeartAnimation双击的爱心
.rk-stories-headerStoryHeader头部一行(头像、名称、时间、控件)
.rk-stories-header--hiddenStoryHeader头部隐藏状态(visible 输入设为 false)
.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当前 Story 加载时的加载指示
.rk-stories-ringStoriesRing带渐变圆环的圆形头像
.rk-stories-ring--activeStoriesRing仍有未看 Story 的圆环(渐变旋转)
.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作为卡片预览缩小绘制的幻灯片模板
.rk-stories-card-scrimCarousel预览上方的压暗层
.rk-stories-card-infoCarousel圆环、作者名称和时间的纵列
.rk-stories-card-nameCarousel作者名称
.rk-stories-card-timeCarousel相对时间文字

主题定制

每一个颜色、尺寸、时长和 z-index 都是 :root 上的 --rk-stories-* 自定义属性,因此换肤只需一份样式表,而不必改动组件。这些令牌与 React、Vue 播放器共用。

令牌默认值控制内容
--rk-stories-overlay-bg#000全屏背景颜色
--rk-stories-overlay-z9999浮层 z-index
--rk-stories-swipe-gap16px导航按钮与 Story 画布之间的间距
--rk-stories-container-radius12pxStory 画布的圆角(桌面端)
--rk-stories-container-radius-mobile0手机上 Story 画布的圆角
--rk-stories-ui-z15界面层(头部、进度条)的 z-index
--rk-stories-ui-transition200mshideUIOnPause 切换时的淡入淡出时长
--rk-stories-top-shade-height120px头部后方顶部渐变遮罩的高度
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)顶部渐变遮罩颜色
--rk-stories-error-z5错误面板的 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-gap12px错误图标与文字之间的间距
--rk-stories-error-text-size13px错误信息字号
--rk-stories-nav-z20桌面端导航按钮的 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-video-bg#000<video> 后方的留黑背景
--rk-stories-video-poster-transition200ms视频开始播放时封面淡出的时长
--rk-stories-progress-bar-z10进度条的 z-index
--rk-stories-progress-bar-padding8px 8px 0进度条周围的留白
--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轮播滑动时长
--rk-stories-ring-spin-duration4s仍有未看 Story 的圆环的旋转时长
--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-header-z5头部的 z-index
--rk-stories-header-top18px头部距 Story 顶部的垂直偏移
--rk-stories-header-padding12px 16px头部行的内边距
--rk-stories-header-gap8px头部行各项之间的间距
--rk-stories-header-transition200ms头部隐藏时的淡出时长
--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-actions-gap8px头部按钮之间的间距
--rk-stories-header-btn-fg#fff头部操作图标颜色(关闭、声音、暂停)
--rk-stories-header-btn-padding4px头部按钮的内边距
--rk-stories-header-spinner-size20px加载指示的尺寸
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)加载指示的轨道颜色
--rk-stories-header-spinner-fg#fff加载指示的弧线颜色
--rk-stories-header-spinner-duration0.8s加载指示转一圈的时长
--rk-stories-heart-z20双击爱心的 z-index
--rk-stories-heart-duration800ms弹出/淡出动画时长

把下面这段放进一个在 @reelkit/angular-stories-player/styles.css 之后加载的样式表。

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关闭播放器