Stories Player для Vue

Повноекранний оверлей плеєра історій у стилі Instagram для Vue 3 на основі @reelkit/vue-stories-player. Він використовує той самий рушій @reelkit/stories-core, що й версія для React, тож поведінка, класи CSS і токени темізації збігаються.

Подивитися демо наживо →

Features

Вкладена навігація
Дотик гортає історії, свайп змінює автора
Відеоісторії
Автовідтворення з перемикачем звуку
Автоперехід
Налаштовний таймер для кожної історії
3D-переходи
Куб, фліп, згасання, зум, зсув
Смуга прогресу
Сегментована смуга на canvas
Фото й відео
Обидва типи медіа
Віртуалізація
Лише 3 слайди в DOM
Лайк подвійним дотиком
Анімація серця на подвійний дотик
Навігація на десктопі
Кнопки-стрілки
Кільця історій
Аватари з кільцем у стилі Instagram
Власні типи
Розширюйте StoryItem своїми полями
Слоти з областю видимості
Замінюйте будь-яку частину інтерфейсу
Стан у URL
Посилання ?story=group.story, якими можна ділитися
Стан переглянутого
Кільця й відновлення переживають перезавантаження

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

bash
npm install @reelkit/vue-stories-player @reelkit/vue lucide-vue-next

Імпортуйте стилі один раз у точці входу застосунку (або в будь-якому компоненті):

typescript
import '@reelkit/vue-stories-player/styles.css';
Іконки

Типовий заголовок і стрілки навігації беруть іконки з lucide-vue-next. Якщо ви віддаєте перевагу іншій бібліотеці іконок, замініть їх через слоти #header і #navigation.

Vue 3.3 або новіша

Оверлеї — узагальнені компоненти, які Vue підтримує починаючи з 3.3. На старішій Vue рантайм відкидає їхні пропси й події, тож пакет вимагає vue 3.3 або новішу як peer-залежність.

Швидкий старт

StoriesOverlay малює повноекранний плеєр. Поєднайте його зі StoriesRingList для точок входу в стилі Instagram, передайте масив об’єктів StoriesGroup і прив’яжіть v-model:is-open.

vue
<script setup lang="ts">
import { ref } from 'vue';
import {
  StoriesOverlay,
  StoriesRingList,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';
import '@reelkit/vue-stories-player/styles.css';

const groups: StoriesGroup[] = [
  {
    author: { id: 'alice', name: 'Alice', avatar: '/alice.jpg', verified: true },
    stories: [
      { id: 's1', mediaType: 'image', src: '/story-1.jpg' },
      { id: 's2', mediaType: 'video', src: '/story-2.mp4', poster: '/poster-2.jpg' },
    ],
  },
  {
    author: { id: 'bob', name: 'Bob', avatar: '/bob.jpg' },
    stories: [{ id: 's3', mediaType: 'image', src: '/story-3.jpg' }],
  },
];

const isOpen = ref(false);
const groupIndex = ref(0);

const openGroup = (index: number) => {
  groupIndex.value = index;
  isOpen.value = true;
};
</script>

<template>
  <StoriesRingList :groups="groups" @select="openGroup" />

  <!-- v-model:is-open keeps the ref in sync however the viewer closes it:
       the ✕ button, a swipe down, Escape, or the last story finishing. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :initial-group-index="groupIndex"
  />
</template>

Стан у URL

Подивитися демо наживо →

StoriesUrlOverlay — окремий компонент, стан відкриття якого живе в адресному рядку. Обидві осі їдуть в одному параметрі — ?story=<group>.<story> — тож на відкриту історію є посилання, яким можна поділитися, зберегти в закладки й закрити кнопкою «Назад». Створіть контролер через useOverlayUrlState і urlIndexTwoAxisKey, а тоді передайте його як controller.

Вбудовані ключі

Історії двовісні, тож розгортайте в контролер двовісний ключ: urlIndexTwoAxisKey (автор і історія за позицією) або urlStableIdTwoAxisKey (автор за стабільним id) — обидва реекспортовані з @reelkit/vue-stories-player. Дивіться посібник зі стану в URL і Core API.

vue
<script setup lang="ts">
import {
  StoriesUrlOverlay,
  useOverlayUrlState,
  urlIndexTwoAxisKey,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';
import { useVueRouterUrlAdapter } from '@reelkit/vue/vue-router-url-adapter';
import '@reelkit/vue-stories-player/styles.css';

const props = defineProps<{ groups: StoriesGroup[] }>();

// The outer axis is the group, the inner one the story inside it.
const stories = useOverlayUrlState({
  param: 'story',
  adapter: useVueRouterUrlAdapter(),
  ...urlIndexTwoAxisKey({
    outerCount: () => props.groups.length,
    innerCounts: () => props.groups.map((group) => group.stories.length),
  }),
});
</script>

<template>
  <!-- Opening is a link — the overlay reads the URL and opens itself. -->
  <RouterLink
    v-for="(group, index) in props.groups"
    :key="group.author.id"
    :to="`?story=${index}.0`"
  >
    {{ group.author.name }}
  </RouterLink>

  <StoriesUrlOverlay :controller="stories" :groups="props.groups" />
</template>
  • Відкриття додає один запис в історію. Перехід між історіями і зміна автора лише замінюють його, тож N переходів не додають записів, а один крок назад завжди закриває плеєр. «Назад» закриває, а не гортає історії.
  • Внутрішня навігація теж їде в URL. Перехід між історіями одного автора оновлює ?story=2.n, тож пряме посилання веде саме на потрібну історію.
  • «Назад» закриває лише тоді, коли плеєр відкрили всередині застосунку — це посилання додало запис. У спільного посилання, відкритого в новій вкладці, історії позаду немає, тож «Назад» веде геть із сайту; кнопка ✕ або Escape прибирає параметр на місці й лишає вас тут.
  • Параметр, що не називає ані автора, ані історії — застаріла закладка, виправлене руками значення, історія за межами групи — прибирається з URL, а не відкриває сусідню.
  • Застосунок із роутером має передати адаптер на його основі, щоб роутер лишався єдиним джерелом правди про навігацію. useVueRouterUrlAdapter із @reelkit/vue/vue-router-url-adapter — готовий адаптер для Vue Router; запис в історію повз роутер лишає його локацію застарілою, і наступна навігація губить параметр.

Стабільні посилання. Типово автор адресується за позицією, тож збережене ?story=2.0 відкриє іншу людину, щойно стрічку перевпорядкують. Адресуйте автора за стабільним id — outerCodec записує id в URL, а outerLocator знаходить, де він стоїть. Передайте innerCodec разом з innerLocate та innerIdentify, щоб так само адресувати історію всередині її групи; без них половина з історією лишається звичайним індексом усередині знайденої групи. Id історії не може містити . — це роздільник між двома половинами.

typescript
// `?story=alice.0` — the group by the author's stable id, the story by its
// position inside whichever group that id resolves to.
const stories = useOverlayUrlState({
  param: 'story',
  adapter: useVueRouterUrlAdapter(),
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.value.length,
    innerCounts: () => groups.value.map((group) => group.stories.length),
    outerCodec: { decode: (raw) => raw, encode: (id) => id },
    outerLocator: {
      locate: (id) =>
        groups.value.findIndex((group) => group.author.id === id),
      identify: (index) => groups.value[index].author.id,
    },
  }),
});

Нескінченні стрічки. Посторінкове завантаження — справа outerLocator і не залежить від кодека. locate синхронний, тож відповідає лише про вже завантажені групи: спільне посилання на групу 400 у стрічці, де завантажено 20, нічого не знайде. locateAsync — запасний шлях, який викликають лише тоді, коли locate не впорався; історія потім обмежується межами тієї групи, на якій усе зупинилося.

Той самий locateAsync, зовнішня вісь

Це той самий пейджер locateAsync, який приймають одновісні ключі — у двовісному ключі він їде у вашому outerLocator, тож сторінки гортає вісь авторів, а історія лишається локальним індексом усередині знайденої групи.

typescript
// A feed that pages. `locate` answers for the groups already loaded;
// `locateAsync` runs only when it misses, so a shared link to a group past
// the window fetches up to it and then opens.
outerLocator: {
  locate: (index) => (index < loaded.value.length ? index : null),
  identify: (index) => index,
  locateAsync: async (index) => {
    if (index >= totalGroups) return null;
    loaded.value = await fetchGroupsUpTo(index);
    return index;
  },
},
  • Поки locateAsync у процесі, плеєр лишається закритим, а параметр — недоторканим, тож пряме посилання переживає запит. null або відмова прибирає параметр.
  • Відповідь, що приходить після зміни URL, після закриття або після демонтажу, відкидається — повільний запит не відкриє історію, якої ніхто не просив.
  • Повний перелік опцій useOverlayUrlState є в довіднику Vue API.

Пам’ять про переглянуте

Кільця світяться градієнтом, доки автора не додивилися до кінця, а група відкривається на першій непереглянутій історії. І те, й те дає один createStoriesViewedStateController, створений там, де живе стрічка, і переданий списку кілець і плеєру як viewed: вони самі читають сховище, стежать за ним і записують показане. Збережений запис називає автора й історію за id, тож він переживає перевпорядкування стрічки, а той самий контролер працює в React та Angular.

vue
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';

const props = defineProps<{ groups: StoriesGroup[] }>();

const isOpen = ref(false);
const groupIndex = shallowRef(0);

// One controller for the rings and the player. It reads the groups through
// the getter every time it needs them, so a feed that pages in more groups
// is counted too.
const viewed = createStoriesViewedStateController({
  storageKey: 'stories-seen',
  groups: () => props.groups,
});

const openGroup = (index: number) => {
  groupIndex.value = index;
  isOpen.value = true;
};
</script>

<template>
  <!-- The rings follow the controller by themselves. -->
  <StoriesRingList
    :groups="props.groups"
    :viewed="viewed"
    @select="openGroup"
  />

  <!-- The player resumes, records, and draws its card rings from it. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="props.groups"
    :initial-group-index="groupIndex"
    :viewed="viewed"
  />
</template>
  • Історія, з якої все почалося, теж рахується. Історію, що вже на екрані, позначають переглянутою після монтування, тож відкрити групу з однієї історії й закрити — означає додивитися її.
  • Рух далі теж відновлюється. resumeStoryIndex питають для кожної групи, до якої дійшли вперше за цей сеанс, зокрема для тієї, з якої плеєр відкрився, якщо initialStoryIndex не називає історію прямо; група, яку вже гортали, відкривається там, де її лишили.
  • Посилання все одно головніше. У StoriesUrlOverlay місце відкриття вирішує параметр, хай що збережено. Всюди інде вирішує колбек відновлення.
  • Лічильник — це позиція, а не підсумок. Запис називає найдальшу досягнуту історію, тож нова історія в додивленій групі знову засвічує кільце, а видалена з середини вкорочує лічильник.
  • Сховище змінне. Передайте storage: createSessionStorageAdapter(), щоб забувати після закриття вкладки, або власний StorageAdapter. Дві відкриті вкладки лишаються в такт завдяки події сховища.
  • Плеєр, змонтований лише на відкриття, читає запізно. Оверлей читає сховище після монтування, поки ще закритий. З v-if="open" такої миті немає, тож викличте useAttachViewedState(() => viewed) у компоненті, який володіє контролером; він під'єднується після монтування й відпускає сховище під час демонтування. Опції та нижчий рівень, з якого це зібрано, описані на сторінці Stories Core.

Поставте desktop-layout="carousel", щоб розкласти плеєр як вебверсію Instagram: активна історія лишається в центрі у звичайному розмірі, а до двох сусідніх авторів стоять з боків меншими притемненими картками. Клік на картці відкриває того автора, і картки з’їжджають, поки змінюється група. Передайте той самий контролер viewed, що й списку кілець, — і картка додивленої групи отримає приглушене кільце. Картки стежать за ним самі, тож коли історію позначають переглянутою, перемальовуються лише вони, а плеєр не рендериться заново.

vue
<template>
  <!-- Neighbouring groups sit beside the active story as dimmed cards on a
       desktop screen. Phones keep the plain player whatever this says. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :viewed="viewed"
    desktop-layout="carousel"
  />
</template>
  • На телефонах лишається звичайний плеєр. До 768px завширшки картки не малюються, а свайпи користуються group-transition, як і в типовому розкладі 'single'. Зміна ширини через цю межу перемикає розклад наживо.
  • Кожна картка показує те, з чого відкриється група: історію, на якій її лишили цього сеансу, інакше — ту, що повертає resumeStoryIndex. Вона показує постер відео або зображення. Історію без обох, як-от текстову на градієнті, малює ваш слот #slide у розмірі плеєра й зменшує; відео без постера показує тло картки. Цей попередній перегляд отримує isActive: false і є лише картинкою: у ньому не можна нічого сфокусувати чи натиснути. Тримайте все, що слайд робить при монтуванні — відтворення чи аналітику — за перевіркою isActive.
  • Таймер чекає на з’їзд карток. Історія не гортається далі, а story-viewed для неї не спрацьовує, доки картки не доїхали.
  • Кожен спосіб змінити групу дає з’їзд: клік на картці, StoriesApi.goToGroup, nextGroup і prevGroup, а також стрілки за межею групи. Свайп рухає сам плеєр, тож з’їзду згори не отримує.

Смуга прогресу й заголовок у кожній групі

Типово одна смуга прогресу й один заголовок лежать над плеєром і перемикаються на нову групу, щойно вона змінилася. Задайте chrome-placement="group", щоб кожна група мала власні всередині свого слайда: тоді обидва повертаються разом із групою, як в Instagram.

vue
<template>
  <!-- Every group carries its own progress bar and header, so both turn with
       the group instead of switching above the player once it has changed. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    chrome-placement="group"
  />
</template>
  • Сусідня група показує, де вона зупинилася. Поки плеєр повертається до неї, її смуга й заголовок показують історію, з якої вона відкриється, ще без жодного перегляду.
  • Група, яку покидають, лишається як була. Її смуга тримає прогрес до кінця повороту, тож досмотрена група лишається заповненою, а не порожніє посеред повороту.
  • Слоти рендеряться для кожної групи на екрані. Області видимості #progressBar і #header містять groupIndex та isActive; для сусідньої групи isActive дорівнює false, а сигнали прогресу не змінюються.
  • Слот #header лежить в області свайпу. Дотик до нього перемикає історії, якщо він не влучив у button, посилання чи елемент із role="button", тож усі елементи керування в ньому мають бути саме такими.
  • Десктопна карусель посеред ковзання не змінюється. Поки картки ковзають, плеєр прихований, тож вибір видно, щойно група відкрилася.

Довідник API

Пропси StoriesOverlay

StoriesOverlayProps<T>

PropТипТипове значенняОпис
aria-labelstring'Stories player'Доступна назва області діалогу; екранні читачі оголошують її, коли оверлей відкривається
chrome-placement'overlay' | 'group''overlay'Де живуть смуга прогресу й заголовок. 'overlay' малює одну копію над плеєром; 'group' малює по одній у кожному слайді групи, тож обидва повертаються разом із групою. Тип експортується як ChromePlacement.
default-image-durationnumber5000Тривалість автопереходу для історії-зображення, у мілісекундах
desktop-layout'single' | 'carousel''single'Розклад на десктопі. 'carousel' показує сусідніх авторів картками поруч з активною історією і з’їжджає між групами. На телефонах історія завжди сама. Тип експортовано як DesktopLayout.
enable-keyboardbooleantrueВмикає навігацію клавіатурою (стрілки вліво/вправо, Escape)
groupsStoriesGroup<T>[]requiredГрупи історій для показу. Групи чи історії, додані поки плеєр відкритий, підхоплюються — і коли масив замінили, і коли дописали в той самий.
group-transitionTransitionTransformFncubeTransitionПерехід для зовнішнього слайдера (авторів). Ігнорується, поки показана десктопна карусель, яка натомість з’їжджає.
hide-ui-on-pausebooleantrueХоває смугу прогресу й заголовок, поки триває пауза від довгого натискання
initial-group-indexnumber0Індекс початково видимої групи, від нуля
initial-story-indexnumberвідновлення, інакше 0Індекс початково видимої історії, від нуля. Названа історія головніша за будь-що збережене; не вказуйте її — і група відкриється з відновленням, як усі інші.
inner-transition-durationnumber200Тривалість згасання між історіями, у мілісекундах
is-openbooleanrequiredКерує видимістю й блокує прокручування сторінки, поки плеєр відкритий. Прив’язуйте через v-model:is-open.
min-segment-widthnumber8Мінімальна ширина сегмента прогресу в пікселях, після якої вмикається рухоме вікно
resume-story-index(groupIndex: number) => number—З якої історії група відкривається, коли до неї доходять уперше. Питають лише про групу, ще не відвідану за це відкриття, і це головніше за viewed.
tap-zone-splitnumber0.3Частка ширини для зони дотику (0–1). Ліва частина веде назад, решта — уперед.
viewedStoriesViewedStateController—Те, що глядач уже бачив, із createStoriesViewedStateController(). Групи відновлюються з першої непереглянутої історії, кожна показана історія записується, а картки каруселі приглушують кільце додивленої групи. Передайте той самий контролер у StoriesRingList.

Пропси StoriesUrlOverlay

StoriesUrlOverlayProps<T>

Приймає всі пропси вище, крім трійці стану відкриття — is-open, initial-group-index, initial-story-index, — яку натомість дає контролер.

PropТипТипове значенняОпис
controllerUrlStateController<TwoAxisPosition>requiredКонтролер із useOverlayUrlState, розгорнутий із urlIndexTwoAxisKey. Його position — об’єкт { outer, inner } — вирішує, чи плеєр відкритий і де саме; оверлей пише назад на кожну навігацію та на закриття.

Події

ПодіяДаніОпис
@api-readyStoriesApiСпрацьовує, щойно плеєр змонтовано, і дає імперативний API
@closevoidСпрацьовує, коли плеєр закривається. У StoriesUrlOverlay параметр на цей момент уже прибрано.
@double-tapgroupIndex: number, storyIndex: numberСпрацьовує на подвійний дотик, разом з анімацією серця
@group-changegroupIndex: numberСпрацьовує після зміни активної групи
@pausevoidСпрацьовує на довге натискання або кнопку паузи в заголовку
@resumevoidСпрацьовує на відпускання або кнопку відтворення в заголовку
@story-changegroupIndex: number, storyIndex: numberСпрацьовує після зміни активної історії
@story-completegroupIndex: number, storyIndex: numberСпрацьовує, коли таймер історії добігає кінця
@story-viewedgroupIndex: number, storyIndex: numberСпрацьовує, коли історія стає видимою, зокрема та, з якої плеєр відкрився
@update:is-openbooleanСпрацьовує на закриття; вмикає `v-model:is-open`

v-model:is-open

Використовуйте v-model:is-open, щоб керувати оверлеєм однією прив’язкою. Пара :is-open + @close теж працює, якщо вам потрібна сама подія.

vue
<script setup lang="ts">
import { ref } from 'vue';
import { StoriesOverlay } from '@reelkit/vue-stories-player';

const isOpen = ref(false);
</script>

<template>
  <button type="button" @click="isOpen = true">Open stories</button>

  <!-- One binding, in both directions. -->
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups" />

  <!-- The explicit pair still works when you need the event itself. -->
  <StoriesOverlay
    :is-open="isOpen"
    :groups="groups"
    @close="isOpen = false"
  />
</template>

Слоти з областю видимості

Вісім слотів замінюють будь-яку частину плеєра. Кожен отримує типізований об’єкт області видимості. Слот, який ви не передали, лишає типовий вигляд — так само як і той, що нічого не намалював, тож v-if, який став хибним, повертає типовий вміст, а не лишає дірку.

СлотОбласть видимостіОпис
#error{ story, storyIndex, groupIndex }Замінює вбудовану панель «Content unavailable», намальовану поверх активної історії
#footer{ author, story, storyIndex }Додає підвал під історією активної групи
#groupPreview{ group, groupIndex, story, offset, viewedCount, onOpen }Наповнює картку десктопної каруселі; плеєр і далі розміщує, масштабує та з’їжджає нею
#header{ author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose }Замінює типовий заголовок (автор, пауза, звук, закриття). З chrome-placement="group" він лежить в області свайпу; див. Смуга прогресу й заголовок у кожній групі.
#loading{ story, storyIndex, groupIndex }Замінює типовий індикатор у заголовку, поки активна історія вантажиться
#navigation{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }Замінює обидві десктопні стрілки
#progressBar{ totalStories, activeIndex, progress, group, groupIndex, isActive }Замінює смугу прогресу на canvas. activeIndex і progress — сигнали ядра, тож проведіть їх через toVueRef.
#slide{ story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }Замінює типові слайди із зображенням і відео
vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Every slot is optional; one you leave out keeps the default, and so
         does one that renders nothing. -->
    <template #header="{ author, isPaused, onTogglePause, onClose }">
      <header class="my-header">
        <img :src="author.avatar" :alt="author.name" />
        <span>{{ author.name }}</span>
        <button type="button" @click="onTogglePause">
          {{ isPaused ? 'Play' : 'Pause' }}
        </button>
        <button type="button" @click="onClose">Close</button>
      </header>
    </template>

    <template #footer="{ author, story }">
      <form class="my-reply" @submit.prevent="reply(author, story)">
        <input v-model="draft" :placeholder="`Reply to ${author.name}…`" />
      </form>
    </template>
  </StoriesOverlay>
</template>

Власні слайди

Складайте ImageStorySlide і VideoStorySlide усередині #slide, щоб зберегти автовідтворення, захоплення постера й синхронізацію звуку, малюючи власний розклад. Викликайте колбеки життєвого циклу з області видимості, інакше таймер ніколи не стартує:

КолбекКоли викликати
onReadyЗображення завантажилося або відео почало грати. Знімає стан завантаження й запускає таймер.
onWaitingВідео буферизується посеред відтворення. Показує індикатор і ставить таймер на паузу.
onErrorВміст не завантажився. Показує стан помилки, а URL запам’ятовується як зламаний, тож повторне відкриття не робить нової спроби.
onDurationReadyПовідомте справжню тривалість медіа в мілісекундах, щоб таймер тривав стільки ж, скільки відео.
onEndedМедіа скінчилося. Гортає до наступної історії.
vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Compose ImageStorySlide and VideoStorySlide to keep autoplay, poster
         capture and sound sync while drawing your own layout around them.
         Pass the callbacks through, or the timer never starts. -->
    <template #slide="scope">
      <div class="my-slide">
        <VideoStorySlide
          v-if="scope.story.mediaType === 'video'"
          :src="scope.story.src"
          :poster="scope.story.poster"
          :group-index="scope.groupIndex"
          :story-index="scope.index"
          :active-group-index="scope.activeGroupIndex"
          :active-story-index="scope.activeStoryIndex"
          :on-duration-ready="scope.onDurationReady"
          :on-playing="scope.onReady"
          :on-waiting="scope.onWaiting"
          :on-ended="scope.onEnded"
          :on-error="scope.onError"
        />
        <ImageStorySlide
          v-else
          :src="scope.story.src"
          :on-load="scope.onReady"
          :on-error="scope.onError"
        />

        <!-- A carousel card draws this same slot with isActive: false, and
             nothing in it can be clicked. Keep playback or analytics behind
             the flag. -->
        <span v-if="scope.isActive" class="my-badge">{{ scope.story.id }}</span>
      </div>
    </template>
  </StoriesOverlay>
</template>
Кешування прелоадера

Вбудовані компоненти ImageStorySlide і VideoStorySlide завантажують наступну історію у фоні. Коли глядач доходить до попередньо завантаженої історії, вміст з’являється миттєво, без індикатора. URL, що один раз не завантажився, теж запам’ятовується, тож повторне відкриття зламаної історії одразу показує помилку замість нової спроби.

Власна навігація

vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Replaces both desktop arrows at once. The first node sits left of
         the story, the rest to its right. -->
    <template #navigation="{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }">
      <div class="my-nav">
        <button type="button" @click="onPrevGroup">Previous user</button>
        <button type="button" @click="onPrevStory">Back</button>
      </div>
      <div class="my-nav">
        <button type="button" @click="onNextGroup">Next user</button>
        <button type="button" @click="onNextStory">Forward</button>
      </div>
    </template>
  </StoriesOverlay>
</template>

Власна смуга прогресу

Область видимості несе сигнали ядра, а не звичайні числа, тож таймер може рухати смугу, не перемальовуючи плеєр. Проведіть їх через toVueRef із @reelkit/vue:

vue
<script setup lang="ts">
import { toVueRef, type Signal } from '@reelkit/vue';

const props = defineProps<{
  totalStories: number;
  activeIndex: Signal<number>;
  progress: Signal<number>;
}>();

// Core signals do not re-render a Vue component on their own: bridge them.
const active = toVueRef(props.activeIndex);
const progress = toVueRef(props.progress);
</script>

<template>
  <div class="my-progress">
    <span
      v-for="index in totalStories"
      :key="index"
      class="my-segment"
      :style="{
        '--fill':
          index - 1 < active
            ? '100%'
            : index - 1 === active
              ? `${progress * 100}%`
              : '0%',
      }"
    />
  </div>
</template>

Власні стани завантаження й помилки

vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Both are drawn over the active story only. Without the loading slot
         the default is the header spinner; without the error slot it is the
         built-in "Content unavailable" panel. -->
    <template #loading="{ story }">
      <p class="my-loading">Loading {{ story.mediaType }}…</p>
    </template>

    <template #error="{ story, groupIndex }">
      <div class="my-error">
        <p>That {{ story.mediaType }} would not load.</p>
        <button type="button" @click="report(groupIndex, story)">Report</button>
      </div>
    </template>
  </StoriesOverlay>
</template>
vue
<template>
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :viewed="viewed"
    desktop-layout="carousel"
  >
    <!-- The player still positions, scales and slides the card; this fills
         it. `offset` is negative on the left, positive on the right, and 0
         for the card passing through the center during a slide. -->
    <template #groupPreview="{ group, story, viewedCount, offset, onOpen }">
      <button type="button" class="my-card" @click="onOpen">
        <img v-if="story?.poster" :src="story.poster" alt="" />
        <span>{{ group.author.name }}</span>
        <small>{{ viewedCount }} / {{ group.stories.length }} seen</small>
        <em v-if="offset === 0">opening…</em>
      </button>
    </template>
  </StoriesOverlay>
</template>

Переходи

group-transition керує 3D-ефектом під час зміни автора. Імпортуйте функції переходів із @reelkit/vue:

vue
<script setup lang="ts">
import { StoriesOverlay } from '@reelkit/vue-stories-player';
import {
  cubeTransition,
  flipTransition,
  fadeTransition,
  zoomTransition,
  slideTransition,
} from '@reelkit/vue';
</script>

<template>
  <!-- cubeTransition is the default; flip, fade, zoom and slide ship too. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :group-transition="flipTransition"
  />
</template>

StoriesApi

Поставте template ref на оверлей для імперативного керування або візьміть API з події @api-ready. Виклики, зроблені поки плеєр закритий, нічого не роблять.

vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { StoriesOverlay, type StoriesApi } from '@reelkit/vue-stories-player';

const isOpen = ref(false);
const player = useTemplateRef<StoriesApi>('player');

// Calls while the player is closed are safely ignored.
const skipUser = () => player.value?.nextGroup();
</script>

<template>
  <button type="button" @click="skipUser">Skip this user</button>

  <StoriesOverlay
    ref="player"
    v-model:is-open="isOpen"
    :groups="groups"
    @api-ready="(api) => api.pause()"
  />
</template>

Методи

МетодТипОпис
nextStory()() => voidПерейти до наступної історії в поточній групі
prevStory()() => voidПовернутися до попередньої історії в поточній групі
nextGroup()() => voidПерейти до наступного автора
prevGroup()() => voidПерейти до попереднього автора
goToGroup(index)(index: number) => voidПерейти до конкретної групи за індексом
pause()() => voidПоставити автоперехід і таймер прогресу на паузу
resume()() => voidПродовжити автоперехід і таймер прогресу

Подвійний дотик і лайки

На подвійний дотик програється анімація серця — миттєвий візуальний відгук. Подія @double-tap дає індекси групи й історії, тож лайк ви зберігаєте у власному стані. Сам плеєр стану лайків не тримає.

vue
<script setup lang="ts">
import { StoriesOverlay, type StoriesGroup } from '@reelkit/vue-stories-player';

const props = defineProps<{ groups: StoriesGroup[] }>();

// The heart animation is built in; the like itself is yours to keep.
const like = (groupIndex: number, storyIndex: number) => {
  const story = props.groups[groupIndex].stories[storyIndex];
  void fetch(`/api/stories/${story.id}/like`, { method: 'POST' });
};
</script>

<template>
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="props.groups"
    @double-tap="like"
  />
</template>

Налаштування анімації серця

Швидкість змінюється токеном --rk-stories-heart-duration (див. Темізацію). Для кольору, розміру чи повного приховування серця беріть клас .rk-stories-heart. Компонент HeartAnimation експортовано й для окремого використання.

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 і програвайте власну анімацію з події @double-tap. Якщо вам потрібен слот heart, напишіть нам через GitHub Issues.

Підкомпоненти

Експортовані будівельні блоки — для складання всередині слотів або для самостійного використання: StoriesRing, StoriesRingList, StoryHeader, CanvasProgressBar, ImageStorySlide, VideoStorySlide і HeartAnimation.

КомпонентЩо це
CanvasProgressBarСегментована смуга прогресу на canvas, намальована просто з сигналів таймера. Тримає рухоме вікно для груп із багатьма історіями. З :live="false" малюється лише при зміні сигналів і після зміни розміру, без циклу анімації.
HeartAnimationСерце подвійного дотику. Випускає complete, коли анімація закінчується, щоб батьківський компонент його прибрав.
ImageStorySlideСлайд-зображення на весь екран з object-fit: cover, що повідомляє про завантаження й помилку колбеками.
StoriesRingКруглий аватар із градієнтним кільцем. Два стани: є що дивитися — кільце обертається, усе переглянуто — стає рівним. Випускає click.
StoriesRingListГоризонтальний ряд кілець з іменами авторів, по одному на групу. Випускає select з індексом групи.
StoryHeaderТиповий заголовок: аватар, ім’я, значок підтвердження, відносний час, пауза, звук, закриття й індикатор завантаження.
VideoStorySlideВідеослайд на одному спільному елементі <video> — для безперервності звуку на iOS. Тримає автовідтворення, кадри-постери й повідомлення про тривалість.
vue
<script setup lang="ts">
import {
  CanvasProgressBar,
  HeartAnimation,
  ImageStorySlide,
  StoriesRing,
  StoriesRingList,
  StoryHeader,
  VideoStorySlide,
} from '@reelkit/vue-stories-player';
</script>

<template>
  <!-- One ring, and the horizontal row of them. -->
  <StoriesRing
    :author="group.author"
    :total-stories="group.stories.length"
    :viewed-count="2"
    :size="72"
    @click="open(group)"
  />
  <StoriesRingList :groups="groups" :viewed="viewed" @select="open" />

  <!-- Inside a #header slot: the default header, with your own additions. -->
  <StoryHeader
    :author="group.author"
    :created-at="story.createdAt"
    :is-paused="isPaused"
    :is-video="story.mediaType === 'video'"
    :is-muted="isMuted"
    :on-toggle-pause="onTogglePause"
    :on-toggle-sound="onToggleSound"
    :on-close="onClose"
  />

  <!-- Inside a #progressBar slot: the canvas bar, driven by the signals. -->
  <CanvasProgressBar
    :total-stories="group.stories.length"
    :active-index="activeIndex"
    :progress="progress"
    :min-segment-width="12"
  />

  <!-- Inside a #slide slot; see the slide slot example above. -->
  <ImageStorySlide :src="story.src" :on-load="onReady" :on-error="onError" />
  <VideoStorySlide
    :src="story.src"
    :group-index="groupIndex"
    :story-index="index"
    :active-group-index="activeGroupIndex"
    :active-story-index="activeStoryIndex"
  />

  <!-- Standalone, outside the player. -->
  <HeartAnimation @complete="remove(heart.id)" />
</template>

Реекспорти

Усе потрібне споживачеві приходить із цього пакета, тож прямий імпорт @reelkit/core не потрібен ніколи.

ЗвідкиЩо реекспортовано
@reelkit/vueObserve, SoundProvider, useSoundState, useOverlayUrlState, createViewedStateController, twoAxisViewedTracking, createLocalStorageAdapter, createSessionStorageAdapter, createMemoryStorageAdapter, urlIndexTwoAxisKey, urlStableIdTwoAxisKey, base64UrlCodec, а також типи UrlAdapter, UrlCodec, UrlLocator, UrlKey, UrlStateController, TwoAxisPosition, TwoAxisIdentity, UrlIndexTwoAxisKeyOptions, OverlayUrlStateOptions, ViewedStateController, ViewedStateOptions, StorageAdapter
@reelkit/stories-corecreateStoriesViewedStateController, StoriesViewedStateController, StoriesViewedStateControllerConfig, а також типи вмісту StoryItem, AuthorInfo, StoriesGroup, MediaType

Observe — та межа рендеру, якою плеєр користується всередині: вона перемальовує лише свій слот, коли змінюється сигнал ядра, і саме так зміна історії оновлює заголовок і смугу прогресу, не перемальовуючи слайдери. Беріть її у власному слоті, коли цілий компонент, що стежить за сигналом через toVueRef, був би заширокою межею — див. довідник Vue API.

Тип пропсів кожного компонента теж експортовано: StoriesOverlayProps, StoriesUrlOverlayProps, StoriesRingProps, StoriesRingListProps, StoryHeaderProps, CanvasProgressBarProps, ImageStorySlideProps і VideoStorySlideProps.

Типи

StoryItem, AuthorInfo, StoriesGroup

typescript
interface StoryItem {
  /** Unique within its group. Stored viewed state keys on it. */
  id: string;
  mediaType: 'image' | 'video';
  src: string;
  /** Poster for a video, also used as the carousel card preview. */
  poster?: string;
  /** Overrides defaultImageDuration for this story, in milliseconds. */
  duration?: number;
  createdAt?: string | Date;
  /** Media aspect ratio (width / height). */
  aspectRatio?: number;
}

interface AuthorInfo {
  /** Stable id. Viewed state and stable-id URLs key on it. */
  id: string;
  name: string;
  avatar: string;
  verified?: boolean;
}

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

Області видимості слотів

Кожен тип області видимості експортовано, тож слот можна анотувати там, де компілятор не виводить тип сам — HeaderSlotScope, FooterSlotScope, SlideSlotScope, NavigationSlotScope, ProgressBarSlotScope, LoadingSlotScope, ErrorSlotScope і GroupPreviewSlotScope, поряд зі StoriesApi:

typescript
interface HeaderSlotScope<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
  isPaused: boolean;
  isMuted: boolean;
  /** True while the active story is a video — gate your sound button on it. */
  isVideo: boolean;
  groupIndex: number;
  /** False for a neighbouring group's header with chrome-placement="group". */
  isActive: boolean;
  onToggleSound: () => void;
  onTogglePause: () => void;
  onClose: () => void;
}

interface FooterSlotScope<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
}

interface SlideSlotScope<T extends StoryItem = StoryItem> {
  story: T;
  index: number;
  groupIndex: number;
  /** False for the copy a carousel card draws. */
  isActive: boolean;
  size: [number, number];
  activeGroupIndex: Signal<number>;
  activeStoryIndex: Signal<number>;
  /** Report the real media length, in milliseconds. */
  onDurationReady: (durationMs: number) => void;
  onReady: () => void;
  onWaiting: () => void;
  onError: () => void;
  onEnded: () => void;
}

interface NavigationSlotScope {
  onPrevStory: () => void;
  onNextStory: () => void;
  onPrevGroup: () => void;
  onNextGroup: () => void;
}

interface ProgressBarSlotScope<T extends StoryItem = StoryItem> {
  totalStories: number;
  activeIndex: Signal<number>;
  /** 0 to 1 for the active story. Bridge it with toVueRef. */
  progress: Signal<number>;
  group: StoriesGroup<T>;
  groupIndex: number;
  /** False: the signals hold still for a neighbouring group. */
  isActive: boolean;
}

interface LoadingSlotScope<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

/** Same shape as LoadingSlotScope. */
interface ErrorSlotScope<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

interface GroupPreviewSlotScope<T extends StoryItem = StoryItem> {
  group: StoriesGroup<T>;
  groupIndex: number;
  /** The story the group would open on, if it has one. */
  story: T | undefined;
  /** Negative left, positive right, 0 while sliding through the center. */
  offset: number;
  viewedCount: number;
  onOpen: () => void;
}

interface StoriesApi {
  nextStory: () => void;
  prevStory: () => void;
  nextGroup: () => void;
  prevGroup: () => void;
  goToGroup: (index: number) => void;
  pause: () => void;
  resume: () => void;
}

Власні типи історій

Розширте StoryItem своїми полями й типізуйте ним групи. StoriesOverlay і StoriesUrlOverlay узагальнені, як і React-плеєр: тип сторіз виводиться з groups, тож групи, чиї сторіз не відповідають StoryItem, не проходять перевірку типів. У Vue слоти не можуть нести цей тип, тому їхні області видимості лишаються нетипізованими, доки ви їх не анотуєте. Анотуйте область видимості слота, щоб додаткові поля лишалися видимими всередині шаблону. Анотацію ніщо не звіряє з groups, тож тримайте їх узгодженими:

vue
<script setup lang="ts">
import { ref } from 'vue';
import {
  StoriesOverlay,
  type SlideSlotScope,
  type StoriesGroup,
  type StoryItem,
} from '@reelkit/vue-stories-player';

const isOpen = ref(false);

interface PromoStory extends StoryItem {
  title?: string;
  ctaHref?: string;
}

const groups: StoriesGroup<PromoStory>[] = [
  {
    author: { id: 'shop', name: 'Shop', avatar: '/shop.jpg' },
    stories: [
      {
        id: 'sale',
        mediaType: 'image',
        src: '/sale.jpg',
        title: 'Spring sale',
        ctaHref: '/sale',
      },
    ],
  },
];
</script>

<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Annotate the scope with your own item type to keep the slot typed. -->
    <template #slide="scope: SlideSlotScope<PromoStory>">
      <article class="promo">
        <h2>{{ scope.story.title }}</h2>
        <a v-if="scope.story.ctaHref" :href="scope.story.ctaHref">Shop now</a>
      </article>
    </template>
  </StoriesOverlay>
</template>

Класи CSS

Класи звичайні (не scoped), тож стилі, завантажені після @reelkit/vue-stories-player/styles.css, можуть перекрити будь-який із них. Вони збігаються з @reelkit/react-stories-player, тож стилі, написані для однієї прив’язки, працюють і для іншої. Для кольорів, розмірів і z-index беріть радше кастомні властивості з розділу Темізація.

КласКомпонентОпис
.rk-stories-overlayOverlayФіксоване повноекранне тло (колір, z-index)
.rk-stories-overlay--carouselOverlayОверлей із показаною десктопною каруселлю
.rk-stories-overlay--slidingOverlayТриває з’їзд каруселі; полотно плеєра сховане
.rk-stories-swipe-wrapperOverlayОбгортка свайпу для закриття (тримає кнопки навігації та полотно)
.rk-stories-containerOverlayЗаокруглене полотно історії (позиція, overflow)
.rk-stories-ui-layerOverlayКонтейнер інтерфейсу (заголовок, прогрес). З chrome-placement="group" — у кожному слайді групи
.rk-stories-ui-layer--hiddenOverlayСхований стан інтерфейсу (перемикає hide-ui-on-pause)
.rk-stories-errorOverlayСтан помилки (іконка й текст по центру)
.rk-stories-error-textOverlayТекст повідомлення про помилку
.rk-stories-nav-btnNavigationДесктопна стрілка вперед/назад
.rk-stories-progress-barCanvasProgressBarОбгортка позиціювання смуги прогресу на canvas
.rk-stories-slide-wrapperGroupОдна група історій (зовнішній слайд)
.rk-stories-storyStoryОдна історія (корінь внутрішнього слайда)
.rk-stories-headerStoryHeaderСмуга заголовка (аватар, ім’я, дії)
.rk-stories-header--hiddenStoryHeaderСхований стан заголовка
.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Індикатор завантаження
.rk-stories-imageImageStorySlideЕлемент історії-зображення
.rk-stories-videoVideoStorySlideКонтейнер історії-відео
.rk-stories-video-elementVideoStorySlideСпільний елемент <video>
.rk-stories-video-posterVideoStorySlideПостер відео (згасає на початку відтворення)
.rk-stories-video-poster--visibleVideoStorySlideСтан видимого постера (до відтворення)
.rk-stories-heartHeartAnimationАнімація серця на подвійний дотик
.rk-stories-ringStoriesRingКільце історії (аватар з анімованою градієнтною рамкою)
.rk-stories-ring--activeStoriesRingКільце, у якому лишилося що дивитися (анімується)
.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Історія без постера й зображення, намальована слотом #slide у розмірі плеєра й зменшена
.rk-stories-card-scrimCarouselШар притемнення над кадром
.rk-stories-card-infoCarouselСтовпчик із кільцем, іменем автора й часом
.rk-stories-card-nameCarouselІм’я автора
.rk-stories-card-timeCarouselТекст відносного часу

Темізація

Кожен колір, розмір, z-index і перехід живе в кастомній властивості CSS, а її типове значення — на :root. Перекрийте одну чи багато на :root або на будь-якому предку оверлея, щоб перетемізувати, не чіпаючи код компонентів. Токени збігаються з @reelkit/react-stories-player, тож тема переноситься між прив’язками.

ТокенТипове значенняЩо керує
--rk-stories-overlay-bg#000Колір повноекранного тла
--rk-stories-overlay-z9999z-index оверлея
--rk-stories-container-radius12pxЗаокруглення полотна історії (десктоп)
--rk-stories-container-radius-mobile0Заокруглення полотна історії на телефоні, до 768px завширшки
--rk-stories-swipe-gap16pxВідступ між кнопками навігації та полотном історії
--rk-stories-top-shade-height120pxВисота верхнього градієнтного затемнення за заголовком
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)Колір верхнього градієнтного затемнення
--rk-stories-ui-transition200msТривалість згасання, коли перемикається hide-ui-on-pause
--rk-stories-ui-z15z-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-nav-z20z-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-text-size13pxРозмір шрифту повідомлення про помилку
--rk-stories-error-gap12pxВідстань між іконкою помилки та її текстом
--rk-stories-error-z5z-index стану помилки
--rk-stories-video-bg#000Тло-леттербокс за <video>
--rk-stories-video-poster-transition200msТривалість згасання постера на початку відтворення
--rk-stories-progress-bar-padding8px 8px 0Відступи навколо смуги прогресу
--rk-stories-progress-bar-z10z-index смуги прогресу
--rk-stories-header-top18pxВертикальний відступ заголовка від верху історії
--rk-stories-header-padding12px 16pxВнутрішні відступи рядка заголовка
--rk-stories-header-z5z-index заголовка
--rk-stories-header-transition200msТривалість згасання заголовка, коли він ховається
--rk-stories-header-gap8pxВідстань між аватаром, іменем і часом
--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-btn-fg#fffКолір іконок дій у заголовку (закриття, звук, пауза)
--rk-stories-header-btn-padding4pxВнутрішній відступ кнопки дії в заголовку
--rk-stories-header-actions-gap8pxВідстань між кнопками дій у заголовку
--rk-stories-header-spinner-size20pxДіаметр індикатора завантаження
--rk-stories-header-spinner-fg#fffКолір дуги індикатора завантаження
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)Колір доріжки індикатора завантаження
--rk-stories-header-spinner-duration0.8sТривалість обертання індикатора завантаження
--rk-stories-heart-duration800msТривалість появи й згасання серця
--rk-stories-heart-z20z-index серця на подвійний дотик
--rk-stories-ring-spin-duration4sТривалість обертання кільця, у якому лишилося що дивитися
--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-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Тривалість з’їзду каруселі
css
/* Loaded after @reelkit/vue-stories-player/styles.css. Every token has a
   default on :root, so override only what you want to change. The names
   match @reelkit/react-stories-player, so a theme ports between bindings. */
:root {
  --rk-stories-overlay-bg: #0f172a;
  --rk-stories-container-radius: 24px;

  --rk-stories-nav-bg: rgba(99, 102, 241, 0.35);
  --rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.65);
  --rk-stories-nav-fg: #fff;

  --rk-stories-header-name-fg: #fef3c7;
  --rk-stories-header-time-fg: rgba(254, 243, 199, 0.7);

  --rk-stories-ring-spin-duration: 2s;
  --rk-stories-heart-duration: 600ms;

  --rk-stories-card-bg: #1e293b;
  --rk-stories-card-transition: 260ms;
}

/* Rings on a light page. Set on the ring list, not :root, so the rings of the
   player's own carousel keep the colours made for its dark backdrop. */
.rk-stories-ring-list {
  --rk-stories-ring-viewed-color: #d4d4d8;
}

Доступність

Корінь оверлея — модальний діалог (role="dialog", aria-modal="true"). Поставте aria-label, щоб змінити те, що оголосить екранний читач; типово це «Stories player».

Оверлей захоплює фокус на відкритті й повертає його тригеру на закритті. Tab і Shift+Tab ходять по фокусованих елементах усередині; фокус, що втік, повертають назад. Зроблено на captureFocusForReturn і createFocusTrap із @reelkit/core, які реекспортує @reelkit/vue.

У десктопній каруселі кожна типова картка — кнопка з підписом «Open stories by» та іменем автора. Картки стоять у порядку табуляції після керування плеєром, і випадають із нього, поки триває з’їзд; картка, що відкриває свою групу, повертає фокус діалогу, а не лишає його на сторінці.

Рендеринг на сервері

Закритий оверлей не малює нічого, а StoriesRingList малює на сервері всі кільця непереглянутими: сховище переглянутого читають лише після монтування, тож серверна розмітка збігається з першим клієнтським рендером. Ширший контекст — у посібнику з SSR.

Клавіатурні скорочення

КлавішаДія
ArrowLeftПопередня історія. На першій історії групи відкриває попередню групу там, де її залишили; на першій групі нічого не робить.
ArrowRightНаступна історія. Після останньої історії групи відкриває наступну групу; на останній групі закриває плеєр.
EscapeЗакрити плеєр