Stories Core

O motor por trás de @reelkit/react-stories-player. TypeScript puro, sem dependência de framework. Use para montar players de stories em Angular, Vue ou JavaScript puro.

Independente de framework
TypeScript puro, nenhuma dependência de framework de DOM
Navegação em dois níveis
Grupos e, dentro de cada grupo, os stories
Tempo dos stories
Avanço automático sobre o timer do core, com pausa e retomada
Progresso em canvas
Barra segmentada pronta para telas Retina, com janela deslizante
Zonas de toque
Detecção configurável de toque à esquerda e à direita
Sinais reativos
Construído sobre as primitivas de sinal de @reelkit/core

Instalação

bash
npm i @reelkit/stories-core

Controlador do Stories

createStoriesController(config, events?) devolve um StoriesController que cuida da navegação entre grupos e stories. Acompanha o estado de pausa, lembra o último story visto de cada grupo e dispara callbacks a cada transição.

Configuração (StoriesControllerConfig)

PropriedadeTipoPadrãoDescrição
groupCountnumberobrigatórioNúmero total de grupos de stories
storyCountsnumber[]obrigatórioQuantidade de stories em cada grupo
initialGroupIndexnumber0Índice do grupo inicial
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), senão 0Índice do story inicial dentro do grupo. Informar um valor vence qualquer coisa lembrada; deixe de fora e o grupo de abertura retoma como todos os outros.
defaultImageDurationnumber5000Duração padrão, em ms, do avanço automático dos stories de imagem
resumeStoryIndex(groupIndex: number) => numberundefinedStory em que um grupo ainda não visitado abre, limitado a um story que o grupo tenha

Eventos (StoriesControllerEvents)

EventoTipoDescrição
onStoryChange(groupIndex, storyIndex) => voidDisparado quando o story ativo muda
onGroupChange(groupIndex) => voidDisparado quando o grupo ativo muda
onStoryViewed(groupIndex, storyIndex) => voidDisparado quando um story fica visível
onStoryComplete(groupIndex, storyIndex) => voidDisparado quando o timer do story termina (antes de avançar)
onComplete() => voidDisparado quando o último story do último grupo acaba
onClose() => voidDisparado quando o overlay deve fechar

Estado (sinais reativos)

SinalTipoDescrição
state.activeGroupIndexSignal<number>Índice do grupo ativo no momento
state.activeStoryIndexSignal<number>Índice do story ativo dentro do grupo
state.isPausedSignal<boolean>Se o avanço automático está pausado

Métodos

MétodoTipoDescrição
nextStory()() => voidAvança dentro do grupo; ao chegar ao fim, passa ao grupo seguinte
prevStory()() => voidVolta dentro do grupo; ao chegar ao início, passa ao grupo anterior
nextGroup()() => voidMuda para o próximo grupo, retomando no último story visto
prevGroup()() => voidMuda para o grupo anterior, retomando no último story visto
goToGroup(index)(number) => voidSalta direto para um grupo pelo índice
pause()() => voidPausa o avanço automático
resume()() => voidRetoma o avanço automático
onStoryTimerComplete()() => voidChamado quando o timer termina; dispara onStoryComplete e então avança
getLastStoryIndex(groupIndex)(number) => numberOnde o grupo abre: o story em que foi deixado nesta sessão, senão o que resumeStoryIndex indicar
reportInitialView()() => voidRegistra, uma única vez, como visto o story em que o player abriu. Chame depois da montagem, não durante a renderização.
updateConfig({ groupCount, storyCounts })(config) => voidSubstitui a contagem de grupos e de stories quando o feed muda com o player aberto, para que os grupos carregados depois possam ser alcançados. Não dispara nenhum evento, mantém onde cada grupo foi deixado e traz as posições de volta para dentro de um feed que encolheu.

Exemplo

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();

Controlador de timer

O timer que conduz o progresso de cada story fica em @reelkit/core como createTimerController, compartilhado com a apresentação do lightbox. Importe-o de @reelkit/core ou da sua binding (@reelkit/react, @reelkit/vue, @reelkit/angular); este pacote não o exporta mais. Veja Reprodução automática na API do core para sua configuração, estado e métodos, e o exemplo do Stories Controller acima para ligá-lo a onStoryTimerComplete.

Renderizador de progresso em Canvas

createCanvasProgressRenderer(config?) desenha barras de progresso segmentadas em um canvas. Ajusta a escala para telas Retina, acompanha a largura do contêiner com observeElementSize de @reelkit/core e passa a uma janela deslizante quando os segmentos não cabem.

Configuração (CanvasProgressRendererConfig)

PropriedadeTipoPadrãoDescrição
gapnumber2Espaço em pixels entre os segmentos
barHeightnumber2Altura da barra em pixels
minSegmentWidthnumber8Largura mínima do segmento antes de a janela deslizante entrar em cena
bgColorstring'rgba(255,255,255,0.3)'Cor de fundo dos segmentos ainda não preenchidos
fillColorstring'#ffffff'Cor de preenchimento dos segmentos concluídos e do ativo
onResize() => void-Chamado depois que o contêiner mudou de tamanho e o canvas foi medido de novo. Redimensionar um canvas o apaga, então redesenhe aqui.

Métodos

createCanvasProgressRenderer devolve um CanvasProgressRenderer, o objeto a guardar quando você tipa um campo de componente ou uma ref que mantém o renderizador:

MembroTipoDescrição
attach(canvas)(HTMLCanvasElement) => voidLiga a um elemento canvas; passa a acompanhar o tamanho do elemento pai
draw(totalStories, activeIndex, progress)(number, number, number) => voidDesenha a barra de progresso para o estado informado
widthnumber (readonly)Largura medida no momento, em pixels de CSS
dispose()() => voidPara de acompanhar o tamanho do elemento pai e limpa o estado interno

Exemplo

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();

Controlador do que já foi visto

createStoriesViewedStateController(config) reúne, em uma única chamada, tudo o que um feed precisa para lembrar o que já foi visto: as contagens dos anéis, onde cada grupo é retomado e o registro do que foi visto, sobre um armazenamento que sobrevive a um recarregamento. Ele não importa nenhum framework, então React, Vue e Angular o usam do mesmo jeito: crie-o uma vez onde o feed vive e passe-o para a lista de anéis e para o player, que cuidam de conectá-lo e de acompanhá-lo. Ele é montado sobre o createViewedStateController do core com a chave de dois eixos por ids estáveis; recorra a eles diretamente quando isto não servir. A entrada nomeia o story mais distante alcançado, e não um total de visualizações, então o lugar do grupo sobrevive à reordenação do feed, enquanto um story removido do meio de um grupo encurta a contagem e acende o anel de novo.

Configuração (StoriesViewedStateControllerConfig)

PropriedadeTipoPadrãoDescrição
storageKeystringobrigatórioChave de armazenamento sob a qual as entradas são gravadas
groups() => StoriesGroup<T>[]obrigatórioLê os grupos atuais. É um getter, chamado toda vez, então um feed que carrega mais grupos só precisa devolvê-los. Mantenha-o atualizado: um getter preso ao primeiro array conta errado todos os grupos carregados depois.
keyUrlKey<Id, TwoAxisPosition>ids estáveisComo uma posição é escrita no armazenamento. Passe a chave que o controlador de URL usa e uma entrada será lida como um link compartilhado.
storageStorageAdapterlocalStorageOnde as entradas ficam guardadas. Um adaptador próprio também é a forma de plugar o seu próprio repositório do que foi visto.
ttlMsnumbernão expiraPor quanto tempo a entrada de um grupo fica lembrada depois do último registro. Cada grupo expira no seu próprio relógio.
maxTracksnumbertodos mantidosQuantos grupos manter. Acima disso, o grupo registrado há mais tempo é descartado na próxima gravação.

storageKey, key e storage são lidos uma única vez, na criação do controlador. Para trocar de armazenamento, crie outro controlador.

StoriesViewedStateController

MembroTipoDescrição
viewedStateSubscribable<Map<string, number>>Stories vistos por id de autor, como signal. Vazio antes de attach().
controllerViewedStateController<TwoAxisPosition>O repositório por baixo, para o que isto não cobrir
resumeStoryIndex(groupIndex)(number) => numberPrimeiro story ainda não visto, ou 0 quando o grupo já foi assistido até o fim
markViewed(groupIndex, storyIndex)(number, number) => voidRegistra um story como visto. O player o chama para cada story mostrado.
forget()() => voidEsquece tudo o que foi visto, aqui e no armazenamento
attach()() => DisposeLê o armazenamento e acompanha as outras abas. É contado, então uma lista de anéis e um player podem se conectar e ser desmontados em qualquer ordem. Os componentes do player o chamam por conta própria.

Exemplo

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();

Funções utilitárias

Funções puras para detectar as zonas de toque e para a matemática da barra de progresso.

FunçãoTipoDescrição
getTapAction(tapX, containerWidth, splitRatio?)(number, number, number?) => 'prev' | 'next'Decide, pela posição, se um toque aciona 'prev' ou 'next'. O splitRatio padrão é 0.3.
getSegments(totalStories, activeIndex, progress)(number, number, number) => SegmentState[]Calcula o estado e a porcentagem preenchida de cada segmento da barra de progresso
getVisibleWindow(totalStories, activeIndex, progress, containerWidth, minSegmentWidth?, gap?)(number, number, number, number, number?, number?) => VisibleWindowCalcula a janela deslizante de segmentos visíveis quando a quantidade total passa da capacidade do contêiner

Funções de layout

Funções puras que o player de cada binding compartilha, para React, Vue e Angular calcularem tamanho e disposição do mesmo jeito. Quem as chama é o binding; a sua aplicação só precisa delas se for montar um player próprio.

Dimensionamento

FunçãoTipoDescrição
getStoriesSize()() => [number, number]Tamanho da tela do story. No celular ocupa a tela inteira; no desktop ocupa a altura da janela menos uma margem de 16px, em 9:16, e só estreita quando a tela e as duas setas não cabem lado a lado. Sem window (renderização no servidor) devolve [0, 0].
parseDurationMs(value)(string) => numberO maior tempo de uma lista de transition-duration calculada, em milissegundos. O que não for tempo conta como zero.

O limite de celular que o getStoriesSize usa é o isMobileWidth, exportado por @reelkit/core e por todos os bindings: 768px ou menos é celular. Veja Detecção de tamanho.

Os deslocamentos são relativos ao grupo ativo: negativos à esquerda, positivos à direita, 0 para o próprio story ativo.

FunçãoTipoDescrição
getCardSize(activeSize)([number, number]) => [number, number]Tamanho de um cartão lateral: 0,4 da altura do story ativo, na mesma proporção 9:16.
getCarouselSlot(offset, activeSize)(number, [number, number]) => CarouselSlotOnde um cartão fica em relação ao centro da janela: { x, width, height }. O primeiro cartão de cada lado passa da seta; cada seguinte soma uma largura de cartão e um espaço.
getCardOffsets(activeGroupIndex, groupCount)(number, number) => number[]Deslocamentos dos cartões desenhados ao redor do grupo ativo em repouso, até dois por lado, sem o próprio ativo.
isCardShown(offset)(number) => booleanSe um cartão a essa distância é desenhado em vez de esmaecido.
getSlotOffset(offset)(number) => numberPosição para onde um cartão vai, limitada a uma casa além das visíveis, para o cartão entrar deslizando ao lado do último visível.
getSlideGroupIndexes(from, to, groupCount)(number, number, number) => number[]Grupos que um deslize entre dois grupos desenha: os cartões em volta de cada ponta, em ordem de grupo. Os grupos por que um salto longo passa ficam de fora.

Apresentação

FunçãoTipoDescrição
getRingPresentation(options)(RingPresentationOptions) => RingPresentationClasse, estilo inline e tamanho do avatar de um anel de story, a partir de totalStories, viewedCount e size. Com algo por ver vira o gradiente cônico girando; visto até o fim fica um viewedColor liso; grupo vazio não ganha anel. Os tamanhos levam a unidade px, então o objeto de estilo serve em qualquer framework. As cores vêm dos tokens --rk-stories-ring-active-gradient e --rk-stories-ring-viewed-color, então a página as troca pelo CSS; gradientColors e viewedColor definem a cor de um só anel.
kStoriesRingSize, kStoriesRingListRingSize, kStoriesCardRingSizenumberDiâmetros de anel que toda binding desenha por padrão, em pixels: 68 para um anel avulso, 64 na lista de anéis, 52 num card do carrossel de desktop. Passe um deles como size para ficar igual aos players.
getPreviewSource(story)(StoryItem | undefined) => string | undefinedImagem que um cartão do carrossel mostra: o poster do story, a própria imagem quando é uma imagem, e nada para um vídeo sem poster. Um cartão sem imagem cai no cartão simples desenhado para um story sem nada a mostrar.
formatTimeAgo(date)(string | Date) => stringTempo relativo curto que o cabeçalho e os cartões do carrossel mostram: now, 5m, 3h, 2d, 2w.

Os tipos CarouselSlot, RingPresentation e RingPresentationOptions são exportados junto com elas.

Tipos

Todas as definições de tipo exportadas por @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>;
}