Stories Core

El motor de @reelkit/react-stories-player. TypeScript puro, sin dependencias de framework. Úsalo para crear reproductores de stories con Angular, Vue o JavaScript sin framework.

Independiente del framework
TypeScript puro, sin dependencias de frameworks de DOM
Navegación en dos niveles
Grupos y stories dentro de cada grupo
Temporizador con RAF
Avance automático con requestAnimationFrame, con pausa y reanudación
Progreso en Canvas
Barra de progreso segmentada lista para Retina, con ventana deslizante
Zonas de toque
Detección configurable de toques a izquierda y derecha
Señales reactivas
Construido sobre las primitivas de señales de @reelkit/core

Instalación

bash
npm i @reelkit/stories-core

Controlador de Stories

createStoriesController(config, events?) gestiona la navegación entre grupos y stories. Sigue el estado de pausa y reanudación, recuerda la última story vista de cada grupo y lanza callbacks en cada transición.

Configuración (StoriesControllerConfig)

PropiedadTipoPor defectoDescripción
groupCountnumberobligatorioNúmero total de grupos de stories
storyCountsnumber[]obligatorioNúmero de stories de cada grupo
initialGroupIndexnumber0Índice del grupo inicial
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), si no 0Índice de la story inicial dentro del grupo. Si lo indicas, se impone a lo que se haya recordado; si no, el grupo inicial se reanuda igual que los demás.
defaultImageDurationnumber5000Duración por defecto del avance automático en las stories con imagen, en ms
resumeStoryIndex(groupIndex: number) => numberundefinedStory con la que se abre un grupo no visitado, limitada a una story que el grupo tenga

Eventos (StoriesControllerEvents)

EventoTipoDescripción
onStoryChange(groupIndex, storyIndex) => voidSe lanza al cambiar la story activa
onGroupChange(groupIndex) => voidSe lanza al cambiar el grupo activo
onStoryViewed(groupIndex, storyIndex) => voidSe lanza cuando una story se hace visible
onStoryComplete(groupIndex, storyIndex) => voidSe lanza al terminar el temporizador de una story (antes de avanzar)
onComplete() => voidSe lanza al terminar la última story del último grupo
onClose() => voidSe lanza cuando el overlay debe cerrarse

Estado (señales reactivas)

SeñalTipoDescripción
state.activeGroupIndexSignal<number>Índice del grupo activo
state.activeStoryIndexSignal<number>Índice de la story activa dentro del grupo
state.isPausedSignal<boolean>Indica si el avance automático está en pausa

Métodos

MétodoTipoDescripción
nextStory()() => voidAvanza dentro del grupo; al llegar al final pasa al grupo siguiente
prevStory()() => voidRetrocede dentro del grupo; al llegar al principio pasa al grupo anterior
nextGroup()() => voidCambia al grupo siguiente y retoma la última story vista
prevGroup()() => voidCambia al grupo anterior y retoma la última story vista
goToGroup(index)(number) => voidSalta a un grupo concreto por su índice
pause()() => voidPausa el avance automático
resume()() => voidReanuda el avance automático
onStoryTimerComplete()() => voidSe llama al terminar el temporizador; lanza onStoryComplete y después avanza
getLastStoryIndex(groupIndex)(number) => numberDónde se abre un grupo: la story en la que se dejó en esta sesión o, si no, la que indique resumeStoryIndex
reportInitialView()() => voidMarca como vista, una sola vez, la story con la que se abrió el reproductor. Llámalo después de montar, no durante el renderizado.

Ejemplo

typescript
import {
  createStoriesController,
  createTimerController,
} from '@reelkit/stories-core';
import { 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 del temporizador

createTimerController(config) hace avanzar las stories con un bucle de requestAnimationFrame. La señal de progreso (de 0 a 1) alimenta la barra de progreso. Pausar y reanudar conserva la posición exacta.

Configuración (TimerControllerConfig)

PropiedadTipoPor defectoDescripción
durationnumberobligatorioDuración por defecto en milisegundos
onComplete() => voidundefinedSe llama cuando el temporizador llega al 100 %

Estado

SeñalTipoDescripción
progressSignal<number>Señal de progreso (de 0 a 1)
isRunningSignal<boolean>Indica si el temporizador está en marcha

Métodos

MétodoTipoDescripción
start(duration?)(number?) => voidInicia (o reinicia) el temporizador con una duración opcional distinta
pause()() => voidCongela el progreso en la posición actual
resume()() => voidContinúa desde la posición congelada
reset()() => voidVuelve el progreso a 0 y lo detiene
dispose()() => voidLibera los recursos

Ejemplo

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

const timer = createTimerController({
  duration: 5000,
  onComplete: () => console.log('Timer finished!'),
});

// Observe progress (0 to 1)
const dispose = reaction(
  () => [timer.progress],
  () => {
    console.log('Progress:', timer.progress.value);
  },
);

// Start with default duration
timer.start();

// Or override duration for a specific story
timer.start(8000);

// Pause/resume preserves exact position
timer.pause();
timer.resume();

// Reset to 0
timer.reset();

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

Renderizador de progreso en Canvas

createCanvasProgressRenderer(config?) dibuja barras de progreso segmentadas en un canvas. Se adapta a pantallas Retina, mide su contenedor con ResizeObserver y usa una ventana deslizante cuando los segmentos no caben.

Configuración (CanvasProgressRendererConfig)

PropiedadTipoPor defectoDescripción
gapnumber2Separación en píxeles entre segmentos
barHeightnumber2Altura de la barra en píxeles
minSegmentWidthnumber8Ancho mínimo de un segmento antes de que entre en juego la ventana deslizante
bgColorstring'rgba(255,255,255,0.3)'Color de fondo de los segmentos sin rellenar
fillColorstring'#ffffff'Color de relleno de los segmentos completados o activos

Métodos

MiembroTipoDescripción
attach(canvas)(HTMLCanvasElement) => voidSe conecta a un elemento canvas e inicia ResizeObserver en su padre
draw(totalStories, activeIndex, progress)(number, number, number) => voidDibuja la barra de progreso para el estado indicado
widthnumber (readonly)Ancho medido actual en píxeles CSS
dispose()() => voidLimpia ResizeObserver y el estado interno

Ejemplo

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

Elementos ya vistos

createStoriesViewedState(controller, groups) lee un ViewedStateController del core con los términos que usa un reproductor (grupos, autores, índices de story) sin que el almacén conozca ninguno de ellos. Devuelve un StoriesViewedState. Crea el almacén con la misma clave que usa la barra de direcciones, con seguimiento por grupo, y una entrada guardada se leerá exactamente igual que el parámetro de un enlace compartido.

ParámetroTipoDescripción
controllerViewedStateController<TwoAxisPosition>Almacén del core creado con la misma clave que la barra de direcciones y con twoAxisViewedTracking para que cada grupo tenga su propia entrada
groups() => StoriesGroup<T>[]Lee los grupos actuales. Es un getter, así que un feed que carga páginas o se reordena después de configurarse se mide en el momento de la llamada.

StoriesViewedState

MétodoTipoDescripción
viewedCounts()() => Map<string, number>Stories vistas por grupo, con el id del autor como clave: la forma que StoriesRingList espera como viewedState
resumeStoryIndex(groupIndex)(number) => numberLa primera story sin ver, o 0 cuando el grupo ya se ha visto hasta el final
markViewed(groupIndex, storyIndex)(number, number) => voidRegistra una story como vista. Conéctalo a onStoryViewed.

Ejemplo

typescript
import { createStoriesViewedState } from '@reelkit/stories-core';
import {
  createViewedStateController,
  urlStableIdTwoAxisKey,
  twoAxisViewedTracking,
} from '@reelkit/core';

const seen = createViewedStateController({
  storageKey: 'stories-seen',
  ...urlStableIdTwoAxisKey({ outerItems, innerItems }),
  ...twoAxisViewedTracking,
});
seen.attach();

const viewed = createStoriesViewedState(seen, () => groups);

viewed.viewedCounts(); // Map { 'user_42' => 2 }
viewed.resumeStoryIndex(0); // 2 — the first story not yet seen
viewed.markViewed(0, 2); // furthest point wins; a rewatch never rewinds

Una entrada indica la story más lejana a la que se llegó, no un recuento de visualizaciones, así que una clave por id mantiene el punto de un grupo aunque el feed se reordene, mientras que quitar una story del medio de un grupo acorta su cuenta y vuelve a encender su anillo.

Funciones de utilidad

Funciones puras para detectar las zonas de toque y calcular la barra de progreso.

FunciónTipoDescripción
getTapAction(tapX, containerWidth, splitRatio?)(number, number, number?) => 'prev' | 'next'Decide según la posición si un toque lanza 'prev' o 'next'. El splitRatio por defecto es 0.3.
getSegments(totalStories, activeIndex, progress)(number, number, number) => SegmentState[]Calcula el estado y el porcentaje de relleno de cada segmento de una barra de progreso
getVisibleWindow(totalStories, activeIndex, progress, containerWidth, minSegmentWidth?, gap?)(number, number, number, number, number?, number?) => VisibleWindowCalcula la ventana deslizante de segmentos visibles cuando el total no cabe en el contenedor

Tipos

Todas las definiciones de tipos que exporta @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';