Stories Core

@reelkit/react-stories-player を支えるエンジンです。純粋な TypeScript で、フレームワークへの依存はありません。Angular、Vue、素の JavaScript でストーリープレイヤーを作るのに使います。

フレームワークに依存しない
純粋な TypeScript で、DOM フレームワークへの依存なし
2 階層のナビゲーション
グループと、各グループ内のストーリー
ストーリーのタイミング
core のタイマーによる自動送り、一時停止と再開にも対応
Canvas の進捗表示
Retina 対応の分割された進捗バー、スライドするウィンドウ付き
タップゾーン
左右のタップ判定を設定できます
リアクティブなシグナル
@reelkit/core のシグナルプリミティブの上に構築

インストール

bash
npm i @reelkit/stories-core

Stories コントローラー

createStoriesController(config, events?) は、グループとストーリーをまたいだナビゲーションを管理する StoriesController を返します。一時停止と再開の状態を追跡し、グループごとに最後に見たストーリーを覚え、切り替えのたびにコールバックを呼びます。

設定(StoriesControllerConfig)

プロパティ型デフォルト説明
groupCountnumber必須ストーリーのグループの総数
storyCountsnumber[]必須各グループのストーリー数
initialGroupIndexnumber0最初のグループのインデックス
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex)、なければ 0グループ内の最初のストーリーのインデックス。指定すると覚えている位置より優先されます。省略すると、開いたグループもほかのグループと同じように続きから始まります。
defaultImageDurationnumber5000画像のストーリーを自動で送るまでのデフォルトの時間(ms)
resumeStoryIndex(groupIndex: number) => numberundefinedまだ訪れていないグループが開くストーリー。そのグループにあるストーリーの範囲に収められます

イベント(StoriesControllerEvents)

イベント型説明
onStoryChange(groupIndex, storyIndex) => voidアクティブなストーリーが変わったときに呼ばれます
onGroupChange(groupIndex) => voidアクティブなグループが変わったときに呼ばれます
onStoryViewed(groupIndex, storyIndex) => voidストーリーが表示されたときに呼ばれます
onStoryComplete(groupIndex, storyIndex) => voidストーリーのタイマーが終わったとき(送る前)に呼ばれます
onComplete() => void最後のグループの最後のストーリーが終わったときに呼ばれます
onClose() => voidオーバーレイを閉じるべきときに呼ばれます

状態(リアクティブなシグナル)

シグナル型説明
state.activeGroupIndexSignal<number>現在アクティブなグループのインデックス
state.activeStoryIndexSignal<number>グループ内で現在アクティブなストーリーのインデックス
state.isPausedSignal<boolean>自動送りが一時停止しているかどうか

メソッド

メソッド型説明
nextStory()() => voidグループ内で進みます。端に達したら次のグループへ移ります
prevStory()() => voidグループ内で戻ります。端に達したら前のグループへ移ります
nextGroup()() => void次のグループへ切り替え、最後に見たストーリーから再開します
prevGroup()() => void前のグループへ切り替え、最後に見たストーリーから再開します
goToGroup(index)(number) => voidインデックスで指定したグループへ移動します
pause()() => void自動送りを一時停止します
resume()() => void自動送りを再開します
onStoryTimerComplete()() => voidタイマーが終わったときに呼ばれ、onStoryComplete を呼んでから進みます
getLastStoryIndex(groupIndex)(number) => numberグループが開く位置です。このセッションで離れたストーリー、なければ resumeStoryIndex が示すストーリー
reportInitialView()() => voidプレイヤーが開いたときのストーリーを、一度だけ既読として報告します。描画中ではなく、マウント後に呼んでください。
updateConfig({ groupCount, storyCounts })(config) => voidプレイヤーを開いたままフィードが変わったときに、グループ数とストーリー数を置き換えます。後から読み込まれたグループにも移動できるようになります。イベントは発火せず、各グループを離れた位置は保たれ、フィードが縮んだ場合は位置をその範囲内に戻します。

例

typescript
import { createStoriesController } from '@reelkit/stories-core';
import { createTimerController, reaction } from '@reelkit/core';

const groups = [
  { stories: ['s1', 's2', 's3'] },
  { stories: ['s4', 's5'] },
];

const controller = createStoriesController(
  {
    groupCount: groups.length,
    storyCounts: groups.map((g) => g.stories.length),
    defaultImageDuration: 5000,
  },
  {
    onStoryChange(groupIndex, storyIndex) {
      console.log('Story changed:', groupIndex, storyIndex);
    },
    onComplete() {
      console.log('All stories viewed');
    },
    onClose() {
      console.log('Overlay closed');
    },
  },
);

// Wire up a timer for auto-advance
const timer = createTimerController({
  duration: 5000,
  onComplete: () => controller.onStoryTimerComplete(),
});

// React to story changes and restart the timer
const dispose = reaction(
  () => [
    controller.state.activeGroupIndex,
    controller.state.activeStoryIndex,
  ],
  () => timer.start(),
);

// Start playback
timer.start();

// Navigation
controller.nextStory();
controller.pause();
controller.resume();

// Cleanup
dispose();
timer.dispose();

タイマーコントローラー

各ストーリーの進捗を動かすタイマーは、@reelkit/core の createTimerController にあり、Lightbox のスライドショーと共有しています。@reelkit/core か、お使いのバインディング(@reelkit/react、@reelkit/vue、@reelkit/angular)からインポートしてください。このパッケージからはもうエクスポートされていません。設定、状態、メソッドは core API の自動再生を、onStoryTimerComplete への組み込み方は上の Stories Controller の例を参照してください。

Canvas プログレスレンダラー

createCanvasProgressRenderer(config?) は分割された進捗バーを canvas に描きます。Retina ディスプレイに合わせて拡大し、@reelkit/core の observeElementSize でコンテナーの幅に追従し、セグメントが収まらないときはスライドするウィンドウを使います。

設定(CanvasProgressRendererConfig)

プロパティ型デフォルト説明
gapnumber2セグメント間の間隔(ピクセル)
barHeightnumber2バーの高さ(ピクセル)
minSegmentWidthnumber8スライドするウィンドウに切り替わる前のセグメントの最小幅
bgColorstring'rgba(255,255,255,0.3)'塗られていないセグメントの背景色
fillColorstring'#ffffff'完了したセグメントとアクティブなセグメントの塗りの色
onResize() => void-コンテナーの大きさが変わり、canvas を測り直したあとに呼ばれます。canvas の大きさを変えると中身が消えるので、ここで描き直してください。

メソッド

createCanvasProgressRenderer は CanvasProgressRenderer を返します。レンダラーを保持するコンポーネントのフィールドや ref の型付けには、このオブジェクトを使います。

メンバー型説明
attach(canvas)(HTMLCanvasElement) => voidcanvas 要素に接続し、親要素の大きさの監視を始めます
draw(totalStories, activeIndex, progress)(number, number, number) => void指定した状態で進捗バーを描きます
widthnumber (readonly)現在計測されている幅(CSS ピクセル)
dispose()() => void親要素の大きさの監視をやめ、内部の状態を片付けます

例

typescript
import { createCanvasProgressRenderer } from '@reelkit/stories-core';

const renderer = createCanvasProgressRenderer({
  gap: 2,
  barHeight: 2,
  fillColor: '#ffffff',
  bgColor: 'rgba(255, 255, 255, 0.3)',
});

// Attach to a canvas element
const canvas = document.querySelector('canvas')!;
renderer.attach(canvas);

// Draw on each animation frame
let frameId: number;

function loop() {
  const totalStories = 5;
  const activeIndex = 2;
  const progress = timer.progress.value; // 0-1

  renderer.draw(totalStories, activeIndex, progress);
  frameId = requestAnimationFrame(loop);
}

frameId = requestAnimationFrame(loop);

// Cleanup
cancelAnimationFrame(frameId);
renderer.dispose();

既読コントローラー

createStoriesViewedStateController(config) は、フィードが既読を覚えておくために必要なものを 1 回の呼び出しでまとめて用意します。リングのカウント、各グループの再開位置、既読の記録を、リロードしても残るストレージの上で扱います。フレームワークを一切インポートしないので、React、Vue、Angular のどれでも同じように使えます。フィードを持つ場所で一度だけ作成し、リング一覧とプレイヤーに渡してください。アタッチと追従はコンポーネント側が行います。コアの createViewedStateController と安定 id の 2 軸キーの上に組まれているので、これが合わない場合はそちらを直接使ってください。エントリーが表すのは閲覧回数の合計ではなく、到達したいちばん先のストーリーです。そのためフィードが並べ替えられてもグループの位置は保たれ、グループの途中からストーリーが削除されると件数が減ってリングがまた点灯します。

設定(StoriesViewedStateControllerConfig)

プロパティ型デフォルト説明
storageKeystring必須エントリを書き込むストレージのキー
groups() => StoriesGroup<T>[]必須現在のグループを読み取ります。getter で、必要になるたびに呼ばれるため、グループを追加で読み込むフィードはそれを返すだけで済みます。常に最新を返してください。最初の配列を返し続ける getter は、後から読み込まれたグループをすべて数え間違えます。
keyUrlKey<Id, TwoAxisPosition>安定した idストレージ内での位置の書き方。URL コントローラーと同じ key を渡すと、保存されたエントリは共有リンクと同じ形で読めます。
storageStorageAdapterlocalStorageエントリの保存先。独自のアダプターは、自前の既読ストアを接続する手段でもあります。
ttlMsnumber期限なしグループのエントリを、最後に記録してからどれだけ覚えておくか。グループごとに独自の時計で期限切れになります。
maxTracksnumberすべて保持保持するグループ数。超えると、記録が最も古いグループが次の書き込み時に捨てられます。

storageKey、key、storage はコントローラーの作成時に一度だけ読み取られます。ストレージを切り替えるには、別のコントローラーを作成してください。

StoriesViewedStateController

メンバー型説明
viewedStateSubscribable<Map<string, number>>作成者 id ごとの既読ストーリー数をシグナルとして提供します。attach() の前は空です。
controllerViewedStateController<TwoAxisPosition>土台となるストア。ここで扱っていない用途に使います
resumeStoryIndex(groupIndex)(number) => numberまだ見ていない最初のストーリー。グループを最後まで見ていれば 0
markViewed(groupIndex, storyIndex)(number, number) => voidストーリーを既読として記録します。プレイヤーが表示したストーリーごとに呼び出します。
forget()() => voidここでもストレージでも、既読をすべて消去します
attach()() => Disposeストレージを読み取り、他のタブの変更に追従します。参照カウント方式なので、リング一覧とプレイヤーはどの順序でアタッチしてもアンマウントしても構いません。プレイヤーのコンポーネントが自分で呼び出します。

例

typescript
import { createStoriesViewedStateController } from '@reelkit/stories-core';

// Once, where the feed lives. The getter is read every time, so it has to
// return the groups as they are now.
const viewed = createStoriesViewedStateController({
  storageKey: 'stories-seen',
  groups: () => groups,
});

// The player components do this themselves when given `viewed`.
const detach = viewed.attach();

viewed.viewedState.value; // Map { 'user_42' => 2 }, and a signal to follow
viewed.resumeStoryIndex(0); // 2 — the first story not yet seen
viewed.markViewed(0, 2); // furthest point wins; a rewatch never rewinds
viewed.forget(); // clears it all

detach();

ユーティリティ関数

タップゾーンの判定と、進捗バーの計算のための純粋関数です。

関数型説明
getTapAction(tapX, containerWidth, splitRatio?)(number, number, number?) => 'prev' | 'next'位置に応じて、タップが 'prev' と 'next' のどちらになるかを決めます。splitRatio のデフォルトは 0.3 です。
getSegments(totalStories, activeIndex, progress)(number, number, number) => SegmentState[]進捗バーの各セグメントの状態と塗りの割合を計算します
getVisibleWindow(totalStories, activeIndex, progress, containerWidth, minSegmentWidth?, gap?)(number, number, number, number, number?, number?) => VisibleWindow総数がコンテナーに収まらないときに、表示するセグメントのスライドするウィンドウを計算します

レイアウトのヘルパー

どのバインディングのプレイヤーも共有する純粋な関数です。おかげで React でも Vue でも Angular でも、大きさと配置はまったく同じになります。呼ぶのはバインディング側で、アプリが直接使うのは自前のプレイヤーを作るときだけです。

サイズ

関数型説明
getStoriesSize()() => [number, number]ストーリーの表示領域の大きさ。スマートフォンでは画面いっぱい、デスクトップでは上下 16px の余白を除いたウィンドウの高さに合わせて 9:16 にし、領域と左右の矢印が横に収まらないときだけ幅を詰めます。window がない(サーバーレンダリング)場合は [0, 0] を返します。
parseDurationMs(value)(string) => number計算された transition-duration のリストの中で最も長い時間をミリ秒で返します。時間でない値は 0 として扱います。

getStoriesSize が使うスマートフォンの境界は isMobileWidth で、@reelkit/core とすべてのバインディングからエクスポートされています。768px 以下がスマートフォンです。サイズの検出を参照してください。

オフセットは表示中のグループからの相対値です。左が負、右が正、0 は表示中のストーリー自身です。

関数型説明
getCardSize(activeSize)([number, number]) => [number, number]横のカードの大きさ。表示中のストーリーの高さの 0.4 倍で、比率は同じ 9:16 です。
getCarouselSlot(offset, activeSize)(number, [number, number]) => CarouselSlotカードがウィンドウ中央から見てどこに置かれるか:{ x, width, height }。各側の最初のカードは矢印をよけ、それ以降はカード 1 枚分の幅と間隔ずつ外へ出ます。
getCardOffsets(activeGroupIndex, groupCount)(number, number) => number[]静止時に表示中のグループの周りに描くカードのオフセット。片側最大 2 枚で、表示中のグループ自身は含みません。
isCardShown(offset)(number) => booleanその距離のカードを、薄く消すのではなく描画するかどうか。
getSlotOffset(offset)(number) => numberカードが移動する位置。表示される範囲の 1 つ外までに丸めるので、カードは最後に見えているカードの横から滑り込みます。
getSlideGroupIndexes(from, to, groupCount)(number, number, number) => number[]2 つのグループ間のスライドで描くグループ。両端それぞれの周りのカードを、グループ順に返します。大きく飛ぶときに通り過ぎるグループは含みません。

見た目

関数型説明
getRingPresentation(options)(RingPresentationOptions) => RingPresentationtotalStories、viewedCount、size から、ストーリーリングのクラス名・インラインスタイル・アバターの大きさを返します。未読が残っていれば回転する円錐グラデーション、見終わっていれば平坦な viewedColor、空のグループならリングなしです。大きさには px が付くので、このスタイルオブジェクトはどのフレームワークでもそのまま使えます。色は --rk-stories-ring-active-gradient と --rk-stories-ring-viewed-color のトークンから取るので、ページは CSS で変えられます。gradientColors と viewedColor は 1 つのリングだけの色を決めます。
kStoriesRingSize, kStoriesRingListRingSize, kStoriesCardRingSizenumber各バインディングが既定で描くリングの直径(ピクセル):単体のリングは 68、リング一覧では 64、デスクトップのカルーセルのカードでは 52。プレーヤーと揃えるには、いずれかを size に渡します。
getPreviewSource(story)(StoryItem | undefined) => string | undefinedカルーセルのカードが見せる画像です。ストーリーの poster、画像であればその画像そのもの、ポスターのない動画なら何もありません。画像のないカードは、見せるものがないストーリー用の素のカードに戻ります。
formatTimeAgo(date)(string | Date) => stringヘッダーとカルーセルのカードが出す短い相対時刻:now、5m、3h、2d、2w。

CarouselSlot、RingPresentation、RingPresentationOptions の型も一緒に公開されています。

型

@reelkit/stories-core がエクスポートするすべての型定義です。

typescript
type MediaType = 'image' | 'video';

interface StoryItem {
  id: string;
  mediaType: MediaType;
  src: string;
  poster?: string;
  duration?: number;
  createdAt?: string | Date;
  aspectRatio?: number;
}

interface AuthorInfo {
  id: string;
  name: string;
  avatar: string;
  verified?: boolean;
}

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

type SegmentStatus = 'completed' | 'active' | 'upcoming';

interface SegmentState {
  status: SegmentStatus;
  fillPercentage: number; // 0-100
}

interface VisibleWindow {
  startIndex: number;
  endIndex: number;
  segments: SegmentState[];
}

type TapAction = 'prev' | 'next';

interface CarouselSlot {
  x: number;
  width: number;
  height: number;
}

interface RingPresentationOptions {
  totalStories: number;
  viewedCount: number;
  size: number; // px
  gradientColors?: string[];
  viewedColor?: string;
}

interface RingPresentation {
  avatarSize: number;
  className: string;
  style: Record<string, string>;
}