Stories Core

Die Engine hinter @reelkit/react-stories-player. Reines TypeScript ohne Abhängigkeiten von einem Framework. Nutze sie, um Stories Player für Angular, Vue oder Vanilla JS zu bauen.

Unabhängig vom Framework
Reines TypeScript, keine Abhängigkeiten von einem DOM-Framework
Navigation auf zwei Ebenen
Gruppen und die Stories innerhalb jeder Gruppe
Timing der Stories
Automatisches Weiterschalten über den Timer des Core, mit Pause und Fortsetzen
Fortschritt auf Canvas
Segmentierte Fortschrittsleiste für Retina mit gleitendem Fenster
Tippzonen
Einstellbare Erkennung von Tippen links und rechts
Reaktive Signals
Gebaut auf den Signal-Bausteinen von @reelkit/core

Installation

bash
npm i @reelkit/stories-core

Stories-Controller

createStoriesController(config, events?) gibt einen StoriesController zurück, der die Navigation über Gruppen und Stories verwaltet. Er verfolgt den Zustand von Pause und Fortsetzen, merkt sich pro Gruppe die zuletzt angesehene Story und löst bei jedem Übergang Callbacks aus.

Konfiguration (StoriesControllerConfig)

EigenschaftTypStandardBeschreibung
groupCountnumberPflichtGesamtzahl der Story-Gruppen
storyCountsnumber[]PflichtAnzahl der Stories in jeder Gruppe
initialGroupIndexnumber0Index der Startgruppe
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), sonst 0Index der Start-Story innerhalb der Gruppe. Eine Angabe hier gewinnt gegen alles Gemerkte; lässt du sie weg, macht die Startgruppe weiter wie jede andere.
defaultImageDurationnumber5000Standarddauer für das automatische Weiterschalten bei Bild-Stories in ms
resumeStoryIndex(groupIndex: number) => numberundefinedStory, bei der eine noch nicht besuchte Gruppe öffnet, begrenzt auf eine Story, die es in der Gruppe gibt

Events (StoriesControllerEvents)

EventTypBeschreibung
onStoryChange(groupIndex, storyIndex) => voidWird ausgelöst, wenn sich die aktive Story ändert
onGroupChange(groupIndex) => voidWird ausgelöst, wenn sich die aktive Gruppe ändert
onStoryViewed(groupIndex, storyIndex) => voidWird ausgelöst, wenn eine Story sichtbar wird
onStoryComplete(groupIndex, storyIndex) => voidWird ausgelöst, wenn der Timer einer Story abgelaufen ist (vor dem Weiterschalten)
onComplete() => voidWird ausgelöst, wenn die letzte Story der letzten Gruppe zu Ende ist
onClose() => voidWird ausgelöst, wenn sich das Overlay schließen soll

Zustand (reaktive Signals)

SignalTypBeschreibung
state.activeGroupIndexSignal<number>Index der aktuell aktiven Gruppe
state.activeStoryIndexSignal<number>Index der aktuell aktiven Story innerhalb der Gruppe
state.isPausedSignal<boolean>Ob das automatische Weiterschalten pausiert ist

Methoden

MethodeTypBeschreibung
nextStory()() => voidGeht innerhalb der Gruppe weiter; überschreitet die Grenze zur nächsten Gruppe
prevStory()() => voidGeht innerhalb der Gruppe zurück; überschreitet die Grenze zur vorherigen Gruppe
nextGroup()() => voidWechselt zur nächsten Gruppe und macht bei der zuletzt angesehenen Story weiter
prevGroup()() => voidWechselt zur vorherigen Gruppe und macht bei der zuletzt angesehenen Story weiter
goToGroup(index)(number) => voidSpringt über den Index zu einer bestimmten Gruppe
pause()() => voidPausiert das automatische Weiterschalten
resume()() => voidSetzt das automatische Weiterschalten fort
onStoryTimerComplete()() => voidWird aufgerufen, wenn der Timer abgelaufen ist; löst onStoryComplete aus und schaltet dann weiter
getLastStoryIndex(groupIndex)(number) => numberWo eine Gruppe öffnet: bei der Story, an der sie in dieser Sitzung verlassen wurde, sonst bei der, die resumeStoryIndex nennt
reportInitialView()() => voidMeldet die Story, bei der der Player geöffnet hat, einmal als angesehen. Nach dem Mounten aufrufen, nicht während des Renderns.
updateConfig({ groupCount, storyCounts })(config) => voidErsetzt die Anzahl der Gruppen und Stories, wenn sich der Feed ändert, während der Player offen ist, sodass später nachgeladene Gruppen erreichbar sind. Löst kein Event aus, behält, wo jede Gruppe verlassen wurde, und holt Positionen in einen geschrumpften Feed zurück.

Beispiel

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

Timer-Controller

Der Timer, der den Fortschritt jeder Story antreibt, liegt in @reelkit/core als createTimerController und wird mit der Diashow der Lightbox geteilt. Importiere ihn aus @reelkit/core oder aus deinem Binding (@reelkit/react, @reelkit/vue, @reelkit/angular); dieses Paket exportiert ihn nicht mehr. Konfiguration, Zustand und Methoden findest du unter Autoplay in der Core-API, und das Beispiel des Stories-Controllers oben zeigt, wie er mit onStoryTimerComplete verbunden wird.

Canvas-Renderer für den Fortschritt

createCanvasProgressRenderer(config?) zeichnet segmentierte Fortschrittsleisten auf ein Canvas. Er skaliert für Retina-Displays, folgt der Breite seines Containers über observeElementSize aus @reelkit/core und nutzt ein gleitendes Fenster, wenn die Segmente nicht hineinpassen.

Konfiguration (CanvasProgressRendererConfig)

EigenschaftTypStandardBeschreibung
gapnumber2Abstand in Pixeln zwischen den Segmenten
barHeightnumber2Höhe der Leiste in Pixeln
minSegmentWidthnumber8Mindestbreite eines Segments, bevor das gleitende Fenster greift
bgColorstring'rgba(255,255,255,0.3)'Hintergrundfarbe der nicht gefüllten Segmente
fillColorstring'#ffffff'Füllfarbe der abgeschlossenen und aktiven Segmente
onResize() => void-Wird aufgerufen, nachdem sich die Größe des Containers geändert hat und das Canvas neu gemessen wurde. Eine Größenänderung leert ein Canvas, also zeichne hier neu.

Methoden

createCanvasProgressRenderer gibt einen CanvasProgressRenderer zurück, das Objekt, das du behältst, wenn du ein Feld einer Komponente oder eine Ref für den Renderer typisierst:

MemberTypBeschreibung
attach(canvas)(HTMLCanvasElement) => voidHängt sich an ein Canvas-Element und beginnt, die Größe des Elternelements zu beobachten
draw(totalStories, activeIndex, progress)(number, number, number) => voidZeichnet die Fortschrittsleiste für den angegebenen Zustand
widthnumber (readonly)Aktuell gemessene Breite in CSS-Pixeln
dispose()() => voidBeendet die Beobachtung der Größe des Elternelements und leert den internen Zustand

Beispiel

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

Controller für den Gesehen-Status

createStoriesViewedStateController(config) ist alles, was ein Feed braucht, um sich das Gesehene zu merken, mit einem einzigen Aufruf: die Zählungen für die Ringe, wo jede Gruppe weitermacht, und das Erfassen, über einen Speicher, der ein Neuladen übersteht. Er importiert kein Framework, also nutzen React, Vue und Angular ihn gleich: Erstelle ihn einmal dort, wo der Feed lebt, und gib ihn an die Ringliste und den Player, die ihn selbst anhängen und ihm folgen. Er baut auf createViewedStateController aus dem Core mit dem Key für stabile IDs auf zwei Achsen auf; greif direkt darauf zurück, wenn das hier nicht passt. Ein Eintrag benennt die weiteste erreichte Story und keine Zahl von Aufrufen, sodass die Stelle einer Gruppe eine Neusortierung des Feeds übersteht, während eine aus der Mitte einer Gruppe entfernte Story deren Zählung verkürzt und ihren Ring wieder leuchten lässt.

Konfiguration (StoriesViewedStateControllerConfig)

EigenschaftTypStandardBeschreibung
storageKeystringPflichtSchlüssel im Speicher, unter dem die Einträge geschrieben werden
groups() => StoriesGroup<T>[]PflichtLiest die aktuellen Gruppen. Ein Getter, der jedes Mal aufgerufen wird, sodass ein Feed, der weitere Gruppen nachlädt, sie nur zurückgeben muss. Halte ihn aktuell: Ein Getter, der am ersten Array hängt, zählt jede später geladene Gruppe falsch.
keyUrlKey<Id, TwoAxisPosition>stabile IDsWie eine Position im Speicher geschrieben wird. Übergib den Key, den der URL-Controller nutzt, und ein gespeicherter Eintrag liest sich wie ein geteilter Link.
storageStorageAdapterlocalStorageWo die Einträge liegen. Über einen eigenen Adapter lässt sich auch ein eigener Speicher für das Gesehene einbinden.
ttlMsnumberläuft nie abWie lange der Eintrag einer Gruppe nach seiner letzten Erfassung gemerkt bleibt. Jede Gruppe läuft nach ihrer eigenen Uhr ab.
maxTracksnumberjede Gruppe bleibtWie viele Gruppen behalten werden. Darüber hinaus wird beim nächsten Schreiben die am längsten nicht erfasste Gruppe entfernt.

storageKey, key und storage werden einmal gelesen, wenn der Controller erstellt wird. Um den Speicher zu wechseln, erstelle einen neuen Controller.

StoriesViewedStateController

MemberTypBeschreibung
viewedStateSubscribable<Map<string, number>>Gesehene Stories pro Autor-ID, als Signal. Leer vor attach().
controllerViewedStateController<TwoAxisPosition>Der Speicher darunter, für alles, was dieser Controller nicht abdeckt
resumeStoryIndex(groupIndex)(number) => numberErste nicht gesehene Story oder 0, sobald die Gruppe bis zum Ende angesehen wurde
markViewed(groupIndex, storyIndex)(number, number) => voidErfasst eine Story als gesehen. Der Player ruft es für jede gezeigte Story auf.
forget()() => voidVergisst alles Gesehene, hier und im Speicher
attach()() => DisposeLiest den Speicher und folgt anderen Tabs. Gezählt, sodass eine Ringliste und ein Player beide anhängen und in beliebiger Reihenfolge unmounten können. Die Player-Komponenten rufen es selbst auf.

Beispiel

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

Hilfsfunktionen

Reine Funktionen für die Erkennung von Tippzonen und die Rechnung der Fortschrittsleiste.

FunktionTypBeschreibung
getTapAction(tapX, containerWidth, splitRatio?)(number, number, number?) => 'prev' | 'next'Bestimmt anhand der Position, ob ein Tippen 'prev' oder 'next' auslöst. Der Standard für splitRatio ist 0.3.
getSegments(totalStories, activeIndex, progress)(number, number, number) => SegmentState[]Berechnet Status und Füllgrad in Prozent für jedes Segment einer Fortschrittsleiste
getVisibleWindow(totalStories, activeIndex, progress, containerWidth, minSegmentWidth?, gap?)(number, number, number, number, number?, number?) => VisibleWindowBerechnet das sichtbare gleitende Fenster der Segmente, wenn ihre Gesamtzahl die Kapazität des Containers übersteigt

Layout-Helfer

Reine Funktionen, die der Player jedes Bindings teilt, damit die Player für React, Vue und Angular gleich groß sind und gleich aufgebaut werden. Ein Binding ruft sie auf; eine App braucht sie nur, wenn sie einen eigenen Player baut.

Größe

FunktionTypBeschreibung
getStoriesSize()() => [number, number]Größe der Fläche für die Story. Auf dem Smartphone füllt sie den Bildschirm; auf dem Desktop füllt sie die Fensterhöhe abzüglich eines Rands von 16px im Format 9:16 und wird nur schmaler, wenn die Fläche und beide Pfeile nicht nebeneinanderpassen. Gibt ohne Fenster [0, 0] zurück, also ist Server-Rendering sicher.
parseDurationMs(value)(string) => numberLängste Zeit in einer berechneten Liste für transition-duration, in Millisekunden. Alles, was keine Zeitangabe ist, zählt als keine.

Die Grenze für Smartphones, die getStoriesSize nutzt, ist isMobileWidth, exportiert aus @reelkit/core und jedem Binding: 768px und schmaler ist ein Smartphone. Siehe Größenerkennung.

Die Versätze sind relativ zur aktiven Gruppe: negativ nach links, positiv nach rechts, 0 für die aktive Story selbst.

FunktionTypBeschreibung
getCardSize(activeSize)([number, number]) => [number, number]Größe einer seitlichen Karte: 0.4 der Höhe der aktiven Story, im selben Format 9:16.
getCarouselSlot(offset, activeSize)(number, [number, number]) => CarouselSlotWo eine Karte relativ zur Mitte des Fensters sitzt: { x, width, height }. Die erste Karte auf jeder Seite hält Abstand zum Pfeil; jede weitere fügt eine Kartenbreite und einen Abstand hinzu.
getCardOffsets(activeGroupIndex, groupCount)(number, number) => number[]Versätze der Karten, die im Ruhezustand um die aktive Gruppe gezeichnet werden, bis zu zwei pro Seite, ohne die aktive.
isCardShown(offset)(number) => booleanOb eine so weit entfernte Karte gezeichnet statt ausgeblendet wird.
getSlotOffset(offset)(number) => numberPlatz, zu dem eine Karte wandert, begrenzt auf einen Platz hinter den gezeigten, sodass eine Karte von neben der letzten sichtbaren hereingleitet.
getSlideGroupIndexes(from, to, groupCount)(number, number, number) => number[]Gruppen, die ein Übergang zwischen zwei Gruppen zeichnet: die Karten um beide Enden, in der Reihenfolge der Gruppen. Die Gruppen, über die ein weiter Sprung hinweggeht, bleiben weg.

Darstellung

FunktionTypBeschreibung
getRingPresentation(options)(RingPresentationOptions) => RingPresentationKlassenname, Inline-Style und Avatargröße eines Story-Rings aus totalStories, viewedCount und size. Bleibt noch etwas anzuschauen, gibt es das rotierende --rk-stories-ring-active-gradient, eine komplett angesehene Gruppe bekommt das flache --rk-stories-ring-viewed-color, eine leere Gruppe keinen Ring; der Style verweist auf diese Tokens, sodass eine Seite jeden Ring über CSS gestalten kann, und gradientColors oder viewedColor setzen die Farbe für einen einzelnen Ring. Größen tragen ihre Einheit px, sodass das Style-Objekt in jedem Framework funktioniert.
kStoriesRingSize, kStoriesRingListRingSize, kStoriesCardRingSizenumberRingdurchmesser, die jedes Binding standardmäßig zeichnet, in Pixeln: 68 für einen einzelnen Ring, 64 in der Ringliste, 52 auf einer Karte des Desktop-Karussells. Übergib einen davon als size, um zu den Playern zu passen.
getPreviewSource(story)(StoryItem | undefined) => string | undefinedBild, das eine Karussellkarte als Vorschau zeigt: das poster der Story, das Bild selbst, wenn es ein Bild ist, und nichts bei einem Video ohne Poster. Eine Karte ohne Bild fällt auf die einfache Karte zurück, die für eine Story ohne Vorschau gezeichnet wird.
formatTimeAgo(date)(string | Date) => stringKurze relative Zeit, die der Header und die Karussellkarten zeigen: now, 5m, 3h, 2d, 2w.

Die Typen CarouselSlot, RingPresentation und RingPresentationOptions werden mit ihnen exportiert.

Typen

Alle Typdefinitionen, die @reelkit/stories-core exportiert.

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

Dieses Paket ist die Engine, kein Player: Es hält die Zustandsmaschine und die Geometrie und überlässt das Rendern einem Binding. Greif zuerst zu einem fertigen Player und zum Binding deines Frameworks — dem virtualisierten Reel, der Brücke für Signals, den Overlay-Helfern —, wenn du einen eigenen baust.

Nächste Schritte