Stories Core

Рушій, на якому працює @reelkit/react-stories-player. Чистий TypeScript, без залежностей від фреймворків. Беріть його, щоб будувати плеєри історій для Angular, Vue чи звичайного JavaScript.

Framework-Agnostic
Чистий TypeScript, жодних залежностей від DOM-фреймворків
Дворівнева навігація
Групи та історії всередині кожної групи
Таймінг історій
Автоперехід на таймері з ядра, з паузою та відновленням
Прогрес на Canvas
Сегментована смуга прогресу з рухомим вікном, готова до Retina
Зони дотику
Налаштовне розпізнавання дотиків ліворуч і праворуч
Реактивні сигнали
Побудовано на примітивах сигналів @reelkit/core

Встановлення

bash
npm i @reelkit/stories-core

Контролер Stories

createStoriesController(config, events?) повертає StoriesController, який керує навігацією між групами та історіями. Стежить за станом паузи, запам’ятовує останню переглянуту історію в кожній групі й викликає колбеки на кожному переході.

Конфігурація (StoriesControllerConfig)

ВластивістьТипТипове значенняОпис
groupCountnumberrequiredЗагальна кількість груп історій
storyCountsnumber[]requiredКількість історій у кожній групі
initialGroupIndexnumber0Початковий індекс групи
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), інакше 0Початковий індекс історії всередині групи. Явно названий переважає над збереженим; пропустіть — і початкова група відновлюється, як і всі інші.
defaultImageDurationnumber5000Типова тривалість автопереходу для історій-зображень у мілісекундах
resumeStoryIndex(groupIndex: number) => numberundefinedІсторія, на якій відкривається ще не відвідана група; обмежена наявними історіями

Події (StoriesControllerEvents)

EventТипОпис
onStoryChange(groupIndex, storyIndex) => voidСпрацьовує, коли змінюється активна історія
onGroupChange(groupIndex) => voidСпрацьовує, коли змінюється активна група
onStoryViewed(groupIndex, storyIndex) => voidСпрацьовує, коли історія стає видимою
onStoryComplete(groupIndex, storyIndex) => voidСпрацьовує, коли завершується таймер історії (перед переходом)
onComplete() => voidСпрацьовує, коли завершується остання історія останньої групи
onClose() => voidСпрацьовує, коли оверлей має закритися

Стан (реактивні сигнали)

SignalТипОпис
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Замінює кількість груп та історій, коли стрічка змінюється при відкритому плеєрі, щоб можна було перейти до груп, завантажених пізніше. Не надсилає жодної події, зберігає місце, де залишили кожну групу, і повертає позиції в межі стрічки, яка скоротилася.

Example

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); цей пакет його більше не експортує. Його конфігурацію, стан і методи див. в розділі Автовідтворення API ядра, а як під’єднати його до onStoryTimerComplete — у прикладі контролера Stories вище.

Рендерер прогресу на Canvas

createCanvasProgressRenderer(config?) малює сегментовані смуги прогресу на canvas. Масштабується під дисплеї Retina, стежить за шириною свого контейнера через observeElementSize з @reelkit/core і вмикає рухоме вікно, коли сегменти не вміщаються.

Config (CanvasProgressRendererConfig)

ВластивістьТипТипове значенняОпис
gapnumber2Проміжок між сегментами в пікселях
barHeightnumber2Висота смуги в пікселях
minSegmentWidthnumber8Мінімальна ширина сегмента, після якої вмикається рухоме вікно
bgColorstring'rgba(255,255,255,0.3)'Колір тла незаповнених сегментів
fillColorstring'#ffffff'Колір заповнення завершених та активних сегментів
onResize() => void-Викликається після того, як контейнер змінив розмір і canvas переміряно. Зміна розміру canvas очищає його, тож перемальовуйте тут.

Методи

createCanvasProgressRenderer повертає CanvasProgressRenderer — об’єкт, яким типізують поле компонента чи ref, що зберігає рендерер:

ЧленТипОпис
attach(canvas)(HTMLCanvasElement) => voidПідключає до елемента canvas; починає стежити за розміром батьківського елемента
draw(totalStories, activeIndex, progress)(number, number, number) => voidМалює смугу прогресу для заданого стану
widthnumber (readonly)Поточна виміряна ширина в пікселях CSS
dispose()() => voidПрипиняє стежити за розміром батьківського елемента й очищає внутрішній стан

Example

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) — це все, що потрібно стрічці, аби пам'ятати переглянуте, одним викликом: лічильники для кілець, місце, з якого відновлюється кожна група, і запис переглядів поверх сховища, яке переживає перезавантаження. Він не імпортує жодного фреймворку, тож React, Vue та Angular користуються ним однаково: створіть його один раз там, де живе стрічка, і передайте списку кілець та плеєру, які самі під'єднують його і стежать за ним. Він зібраний на createViewedStateController ядра з двовісним ключем за стабільними id; беріть їх напряму, коли це не підходить. Запис називає найдальшу досягнуту історію, а не підрахунок переглядів, тож місце групи переживає перевпорядкування стрічки, а вилучена із середини групи історія скорочує лічильник і знову засвічує кільце.

Конфігурація (StoriesViewedStateControllerConfig)

ВластивістьТипТипове значенняОпис
storageKeystringобов'язковоКлюч сховища, під яким записуються записи
groups() => StoriesGroup<T>[]обов'язковоЧитає поточні групи. Це getter, він викликається щоразу, тож стрічці, яка довантажує групи, досить їх повертати. Тримайте його актуальним: getter, що застряг на першому масиві, неправильно рахує всі групи, завантажені пізніше.
keyUrlKey<Id, TwoAxisPosition>стабільні idЯк позиція записується у сховищі. Передайте ключ, яким користується контролер URL, і запис читатиметься як посилання, яким поділилися.
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();

Utility Functions

Чисті функції для розпізнавання зон дотику та обчислень смуги прогресу.

FunctionТипОпис
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 і вужчає лише тоді, коли полотно й обидві стрілки не вміщаються поперек. Без вікна (рендеринг на сервері) повертає [0, 0].
parseDurationMs(value)(string) => numberНайдовший час зі списку обчисленого transition-duration, у мілісекундах. Усе, що не є часом, рахується як нуль.

Межа телефона, яку використовує 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 }. Перша картка з кожного боку обходить стрілку, кожна наступна додає ширину картки й проміжок.
getCardOffsets(activeGroupIndex, groupCount)(number, number) => number[]Зсуви карток, намальованих навколо активної групи у спокої, до двох з боку, без самої активної.
isCardShown(offset)(number) => booleanЧи картка на такій відстані малюється, а не згасає.
getSlotOffset(offset)(number) => numberСлот, до якого їде картка, обмежений на одне місце далі за показані, щоб картка в’їжджала збоку від останньої видимої.
getSlideGroupIndexes(from, to, groupCount)(number, number, number) => number[]Групи, які малює з’їзд між двома групами: картки навколо обох кінців, у порядку груп. Групи, через які перестрибує далекий перехід, пропускаються.

Вигляд

ФункціяТипОпис
getRingPresentation(options)(RingPresentationOptions) => RingPresentationКлас, інлайн-стиль і розмір аватара кільця історії з totalStories, viewedCount і size. Якщо лишилося що дивитися — конічний градієнт, що обертається; додивлена група — рівний viewedColor; порожня — без кільця. Розміри несуть одиниці px, тож об’єкт стилю годиться для будь-якого фреймворку. Кольори беруться з токенів --rk-stories-ring-active-gradient і --rk-stories-ring-viewed-color, тож сторінка змінює їх у CSS; gradientColors і viewedColor задають колір для одного кільця.
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.

Types

Усі визначення типів, експортовані з @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>;
}