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
Tiempos de las stories
Avance automático con el temporizador del core, 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?) devuelve un StoriesController que 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.
updateConfig({ groupCount, storyCounts })(config) => voidSustituye el número de grupos y de stories cuando el feed cambia con el reproductor abierto, para que se pueda llegar a los grupos cargados después. No dispara ningún evento, conserva dónde se dejó cada grupo y devuelve las posiciones al interior de un feed que se ha reducido.

Ejemplo

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 del temporizador

El temporizador que mueve el progreso de cada story vive en @reelkit/core como createTimerController, compartido con el pase de diapositivas del lightbox. Impórtalo desde @reelkit/core o desde tu binding (@reelkit/react, @reelkit/vue, @reelkit/angular); este paquete ya no lo exporta. Consulta Reproducción automática en la API del core para su configuración, estado y métodos, y el ejemplo del Controlador de Stories de arriba para conectarlo a onStoryTimerComplete.

Renderizador de progreso en Canvas

createCanvasProgressRenderer(config?) dibuja barras de progreso segmentadas en un canvas. Se adapta a pantallas Retina, sigue el ancho de su contenedor mediante observeElementSize de @reelkit/core 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
onResize() => void-Se llama después de que el contenedor cambie de tamaño y el canvas se vuelva a medir. Redimensionar un canvas lo borra, así que vuelve a dibujar aquí.

Métodos

createCanvasProgressRenderer devuelve un CanvasProgressRenderer, el objeto que debes guardar cuando tipas un campo de componente o una ref que contiene el renderizador:

MiembroTipoDescripción
attach(canvas)(HTMLCanvasElement) => voidSe conecta a un elemento canvas y empieza a vigilar el tamaño de 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()() => voidDeja de vigilar el tamaño del padre y limpia 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();

Controlador de lo ya visto

createStoriesViewedStateController(config) reúne, en una sola llamada, todo lo que un feed necesita para recordar lo ya visto: los contadores de los anillos, dónde se reanuda cada grupo y el registro de lo visto, sobre un almacenamiento que sobrevive a una recarga. No importa ningún framework, así que React, Vue y Angular lo usan igual: créalo una vez donde vive el feed y pásaselo a la lista de anillos y al reproductor, que se encargan de conectarlo y de seguirlo. Está construido sobre createViewedStateController del core con la clave de dos ejes por ids estables; recurre a ellos directamente cuando esto no encaje. Una entrada indica la story más lejana a la que se llegó, no un recuento de visualizaciones, así que el punto de un grupo sobrevive a que el feed se reordene, mientras que quitar una story del medio de un grupo acorta su cuenta y vuelve a encender su anillo.

Configuración (StoriesViewedStateControllerConfig)

PropiedadTipoPor defectoDescripción
storageKeystringobligatorioClave de almacenamiento bajo la que se escriben las entradas
groups() => StoriesGroup<T>[]obligatorioLee los grupos actuales. Es un getter y se llama cada vez, así que a un feed que carga más grupos le basta con devolverlos. Mantenlo al día: un getter anclado al primer array cuenta mal todos los grupos cargados después.
keyUrlKey<Id, TwoAxisPosition>ids establesCómo se escribe una posición en el almacenamiento. Pasa la clave que usa el controlador de URL y una entrada se leerá como un enlace compartido.
storageStorageAdapterlocalStorageDónde se guardan las entradas. Un adaptador propio es también la forma de conectar tu propio almacén de lo visto.
ttlMsnumberno caducaCuánto tiempo se recuerda la entrada de un grupo desde la última vez que se registró. Cada grupo caduca con su propio reloj.
maxTracksnumberse conservan todosCuántos grupos conservar. Al superarlo, el grupo registrado hace más tiempo se descarta en la siguiente escritura.

storageKey, key y storage se leen una sola vez, al crear el controlador. Para cambiar de almacenamiento, crea otro controlador.

StoriesViewedStateController

MiembroTipoDescripción
viewedStateSubscribable<Map<string, number>>Stories vistas por id de autor, como señal. Vacío antes de attach().
controllerViewedStateController<TwoAxisPosition>El almacén subyacente, para lo que esto no cubra
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. El reproductor lo llama por cada story mostrada.
forget()() => voidOlvida todo lo visto, aquí y en el almacenamiento
attach()() => DisposeLee el almacenamiento y sigue a las demás pestañas. Lleva la cuenta, así que una lista de anillos y un reproductor pueden conectarse y desmontarse en cualquier orden. Los componentes del reproductor lo llaman por su cuenta.

Ejemplo

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

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

Funciones de disposición

Funciones puras que comparte el player de cada binding, de modo que React, Vue y Angular calculan tamaños y colocación igual. Quien las llama es el binding; tu aplicación solo las necesita si vas a construir un player propio.

Tamaños

FunciónTipoDescripción
getStoriesSize()() => [number, number]Tamaño del lienzo de la story. En el móvil ocupa toda la pantalla; en escritorio ocupa la altura de la ventana menos un margen de 16px, en 9:16, y solo se estrecha cuando el lienzo y las dos flechas no caben a lo ancho. Sin window (renderizado en el servidor) devuelve [0, 0].
parseDurationMs(value)(string) => numberEl tiempo más largo de una lista de transition-duration calculada, en milisegundos. Lo que no sea un tiempo cuenta como cero.

El punto de corte de móvil que usa getStoriesSize es isMobileWidth, exportado desde @reelkit/core y desde cada binding: 768px o menos es un móvil. Consulta Detección de tamaño.

Los desplazamientos son relativos al grupo activo: negativos a la izquierda, positivos a la derecha y 0 para la propia story activa.

FunciónTipoDescripción
getCardSize(activeSize)([number, number]) => [number, number]Tamaño de una tarjeta lateral: 0,4 de la altura de la story activa, con la misma proporción 9:16.
getCarouselSlot(offset, activeSize)(number, [number, number]) => CarouselSlotDónde se sitúa una tarjeta respecto al centro de la ventana: { x, width, height }. La primera tarjeta de cada lado deja pasar la flecha; cada siguiente suma un ancho de tarjeta y un espacio.
getCardOffsets(activeGroupIndex, groupCount)(number, number) => number[]Desplazamientos de las tarjetas que se dibujan alrededor del grupo activo en reposo, hasta dos por lado, sin contar la activa.
isCardShown(offset)(number) => booleanSi una tarjeta a esa distancia se dibuja en lugar de atenuarse.
getSlotOffset(offset)(number) => numberPosición a la que se mueve una tarjeta, limitada a un hueco más allá de las visibles, para que entre deslizándose junto a la última que se ve.
getSlideGroupIndexes(from, to, groupCount)(number, number, number) => number[]Grupos que dibuja un desplazamiento entre dos grupos: las tarjetas alrededor de cada extremo, en orden de grupo. Los grupos por los que pasa un salto largo se quedan fuera.

Presentación

FunciónTipoDescripción
getRingPresentation(options)(RingPresentationOptions) => RingPresentationClase, estilo en línea y tamaño del avatar de un anillo de story, a partir de totalStories, viewedCount y size. Si queda algo por ver, el degradado cónico giratorio; visto entero, un viewedColor liso; un grupo vacío, sin anillo. Los tamaños llevan su unidad px, así que el objeto de estilo sirve en cualquier framework. Los colores salen de los tokens --rk-stories-ring-active-gradient y --rk-stories-ring-viewed-color, así que una página los cambia desde CSS; gradientColors y viewedColor fijan el color de un solo anillo.
kStoriesRingSize, kStoriesRingListRingSize, kStoriesCardRingSizenumberDiámetros de anillo que cada binding dibuja por defecto, en píxeles: 68 para un anillo suelto, 64 en la lista de anillos, 52 en una tarjeta del carrusel de escritorio. Pasa uno como size para coincidir con los reproductores.
getPreviewSource(story)(StoryItem | undefined) => string | undefinedImagen que previsualiza una tarjeta del carrusel: el poster del story, la propia imagen cuando es una imagen, y nada para un vídeo sin póster. Una tarjeta sin imagen recae en la tarjeta simple que se dibuja para un story que no tiene nada que mostrar.
formatTimeAgo(date)(string | Date) => stringEl tiempo relativo corto que muestran la cabecera y las tarjetas del carrusel: now, 5m, 3h, 2d, 2w.

Junto a ellas se exportan los tipos CarouselSlot, RingPresentation y RingPresentationOptions.

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';

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>;
}