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.
Instalación
npm i @reelkit/stories-coreControlador 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)
| Propiedad | Tipo | Por defecto | Descripción |
|---|---|---|---|
groupCount | number | obligatorio | Número total de grupos de stories |
storyCounts | number[] | obligatorio | Número de stories de cada grupo |
initialGroupIndex | number | 0 | Índice del grupo inicial |
initialStoryIndex | number | resumeStoryIndex(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. |
defaultImageDuration | number | 5000 | Duración por defecto del avance automático en las stories con imagen, en ms |
resumeStoryIndex | (groupIndex: number) => number | undefined | Story con la que se abre un grupo no visitado, limitada a una story que el grupo tenga |
Eventos (StoriesControllerEvents)
| Evento | Tipo | Descripción |
|---|---|---|
onStoryChange | (groupIndex, storyIndex) => void | Se lanza al cambiar la story activa |
onGroupChange | (groupIndex) => void | Se lanza al cambiar el grupo activo |
onStoryViewed | (groupIndex, storyIndex) => void | Se lanza cuando una story se hace visible |
onStoryComplete | (groupIndex, storyIndex) => void | Se lanza al terminar el temporizador de una story (antes de avanzar) |
onComplete | () => void | Se lanza al terminar la última story del último grupo |
onClose | () => void | Se lanza cuando el overlay debe cerrarse |
Estado (señales reactivas)
| Señal | Tipo | Descripción |
|---|---|---|
state.activeGroupIndex | Signal<number> | Índice del grupo activo |
state.activeStoryIndex | Signal<number> | Índice de la story activa dentro del grupo |
state.isPaused | Signal<boolean> | Indica si el avance automático está en pausa |
Métodos
| Método | Tipo | Descripción |
|---|---|---|
nextStory() | () => void | Avanza dentro del grupo; al llegar al final pasa al grupo siguiente |
prevStory() | () => void | Retrocede dentro del grupo; al llegar al principio pasa al grupo anterior |
nextGroup() | () => void | Cambia al grupo siguiente y retoma la última story vista |
prevGroup() | () => void | Cambia al grupo anterior y retoma la última story vista |
goToGroup(index) | (number) => void | Salta a un grupo concreto por su índice |
pause() | () => void | Pausa el avance automático |
resume() | () => void | Reanuda el avance automático |
onStoryTimerComplete() | () => void | Se llama al terminar el temporizador; lanza onStoryComplete y después avanza |
getLastStoryIndex(groupIndex) | (number) => number | Dónde se abre un grupo: la story en la que se dejó en esta sesión o, si no, la que indique resumeStoryIndex |
reportInitialView() | () => void | Marca 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) => void | Sustituye 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
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)
| Propiedad | Tipo | Por defecto | Descripción |
|---|---|---|---|
gap | number | 2 | Separación en píxeles entre segmentos |
barHeight | number | 2 | Altura de la barra en píxeles |
minSegmentWidth | number | 8 | Ancho mínimo de un segmento antes de que entre en juego la ventana deslizante |
bgColor | string | 'rgba(255,255,255,0.3)' | Color de fondo de los segmentos sin rellenar |
fillColor | string | '#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:
| Miembro | Tipo | Descripción |
|---|---|---|
attach(canvas) | (HTMLCanvasElement) => void | Se conecta a un elemento canvas y empieza a vigilar el tamaño de su padre |
draw(totalStories, activeIndex, progress) | (number, number, number) => void | Dibuja la barra de progreso para el estado indicado |
width | number (readonly) | Ancho medido actual en píxeles CSS |
dispose() | () => void | Deja de vigilar el tamaño del padre y limpia el estado interno |
Ejemplo
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)
| Propiedad | Tipo | Por defecto | Descripción |
|---|---|---|---|
storageKey | string | obligatorio | Clave de almacenamiento bajo la que se escriben las entradas |
groups | () => StoriesGroup<T>[] | obligatorio | Lee 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. |
key | UrlKey<Id, TwoAxisPosition> | ids estables | Có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. |
storage | StorageAdapter | localStorage | Dónde se guardan las entradas. Un adaptador propio es también la forma de conectar tu propio almacén de lo visto. |
ttlMs | number | no caduca | Cuánto tiempo se recuerda la entrada de un grupo desde la última vez que se registró. Cada grupo caduca con su propio reloj. |
maxTracks | number | se conservan todos | Cuá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
| Miembro | Tipo | Descripción |
|---|---|---|
viewedState | Subscribable<Map<string, number>> | Stories vistas por id de autor, como señal. Vacío antes de attach(). |
controller | ViewedStateController<TwoAxisPosition> | El almacén subyacente, para lo que esto no cubra |
resumeStoryIndex(groupIndex) | (number) => number | La primera story sin ver, o 0 cuando el grupo ya se ha visto hasta el final |
markViewed(groupIndex, storyIndex) | (number, number) => void | Registra una story como vista. El reproductor lo llama por cada story mostrada. |
forget() | () => void | Olvida todo lo visto, aquí y en el almacenamiento |
attach() | () => Dispose | Lee 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
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ón | Tipo | Descripció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?) => VisibleWindow | Calcula 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ón | Tipo | Descripció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) => number | El 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.
Geometría del carrusel en escritorio
Los desplazamientos son relativos al grupo activo: negativos a la izquierda, positivos a la derecha y 0 para la propia story activa.
| Función | Tipo | Descripció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]) => CarouselSlot | Dó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) => boolean | Si una tarjeta a esa distancia se dibuja en lugar de atenuarse. |
getSlotOffset(offset) | (number) => number | Posició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ón | Tipo | Descripción |
|---|---|---|
getRingPresentation(options) | (RingPresentationOptions) => RingPresentation | Clase, 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, kStoriesCardRingSize | number | Diá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 | undefined | Imagen 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) => string | El 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.
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>;
}