diff --git a/src/app/embed/[username]/live-topic.tsx b/src/app/embed/[username]/live-topic.tsx
index 099fda3..565ea41 100644
--- a/src/app/embed/[username]/live-topic.tsx
+++ b/src/app/embed/[username]/live-topic.tsx
@@ -2,9 +2,11 @@
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
-import type { LiveTopic } from "~/utils/twitch-server";
+import type { LiveTopic } from "~/utils/markers";
const REFRESH_MS = 10_000;
+// Longer last topic names are left out, so the line stays short
+const MAX_LAST_TOPIC_CHARS = 20;
// "1:02:03" or "2:03"
const formatElapsed = (ms: number) => {
@@ -47,12 +49,23 @@ export const LiveTopicView = (
{formatElapsed(now - props.topic.startedAt)}
+ {props.topic.lastTopic && (
+ // Duration first, so a narrow view cuts off the name, not the time
+
+ Last topic: {formatElapsed(props.topic.lastTopic.ms)}
+ {props.topic.lastTopic.label.length <= MAX_LAST_TOPIC_CHARS &&
+ ` (${props.topic.lastTopic.label})`}
+
+ )}
>
) : (
{props.status}
diff --git a/src/utils/markers.test.ts b/src/utils/markers.test.ts
index cb1e1fd..95e0e88 100644
--- a/src/utils/markers.test.ts
+++ b/src/utils/markers.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildSegments,
formatSeconds,
+ getLiveTopicFromMarkers,
parseMarkerLabel,
parseOffsetValue,
parseRewind,
@@ -259,3 +260,38 @@ describe("exports", () => {
);
});
});
+
+describe("getLiveTopicFromMarkers", () => {
+ const streamStart = Date.parse("2026-10-07T00:00:00Z");
+ // A marker placed this many minutes into the stream
+ const liveMarker = (minutes: number, description: string) => ({
+ created_at: new Date(streamStart + minutes * 60_000).toISOString(),
+ description,
+ });
+ const lastTopic = (markers: { created_at: string; description: string }[]) =>
+ getLiveTopicFromMarkers({ markers, streamStart }).lastTopic;
+
+ it("gives the topic before the current one and how long it ran", () => {
+ expect(
+ lastTopic([liveMarker(0, "Chrome"), liveMarker(12, "Rust")])
+ ).toEqual({ label: "Chrome", ms: 12 * 60_000 });
+ });
+
+ it("counts the Intro as the first topic", () => {
+ expect(lastTopic([liveMarker(5, "Chrome")])).toEqual({
+ label: "Intro",
+ ms: 5 * 60_000,
+ });
+ expect(lastTopic([])).toBeNull();
+ });
+
+ it("ends the last topic at an END marker", () => {
+ expect(
+ lastTopic([
+ liveMarker(0, "Chrome"),
+ liveMarker(10, "END: Chrome"),
+ liveMarker(15, "Rust"),
+ ])
+ ).toEqual({ label: "Chrome", ms: 10 * 60_000 });
+ });
+});
diff --git a/src/utils/markers.ts b/src/utils/markers.ts
index 81cf996..7846932 100644
--- a/src/utils/markers.ts
+++ b/src/utils/markers.ts
@@ -190,3 +190,74 @@ export function toYouTubeChapters(segments: Segment[]): string {
)
.join("\n");
}
+
+export type LiveTopic = {
+ label: string;
+ type: "start" | "end";
+ // Unix ms. Includes any "-2" style rewind on the marker.
+ startedAt: number;
+ // The topic before this one, and how long it ran in ms. Null when there is none.
+ lastTopic: { label: string; ms: number } | null;
+};
+
+// Picks the current topic from the markers on a live stream.
+// The current topic is the last marker placed on this stream. Offset
+// markers are not topics. Markers from before the stream started are from
+// an older VOD, in case Twitch has not made one for this stream yet.
+export function getLiveTopicFromMarkers(opts: {
+ markers: { created_at: string; description: string }[];
+ // Unix ms
+ streamStart: number;
+}): LiveTopic {
+ const { streamStart } = opts;
+ const topics = opts.markers
+ .filter((marker) => Date.parse(marker.created_at) >= streamStart)
+ .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))
+ .map((marker) => {
+ const { rewindSeconds, description } = parseRewind(marker.description);
+ return {
+ ...parseMarkerLabel(description),
+ // A "-2" rewind only moves the timer start, not which topic is current
+ startedAt: Math.max(
+ Date.parse(marker.created_at) - rewindSeconds * 1000,
+ streamStart
+ ),
+ };
+ })
+ .filter(
+ (topic): topic is Omit => topic.type !== "offset"
+ );
+
+ // Same "Intro" fallback as the VOD page, unless a topic starts with the stream
+ const hasStartAtStreamStart = topics.some(
+ (t) => t.type === "start" && t.startedAt === streamStart
+ );
+ const withIntro = hasStartAtStreamStart
+ ? topics
+ : [
+ { label: "Intro", type: "start" as const, startedAt: streamStart },
+ ...topics,
+ ];
+
+ // The last topic is the newest start before the current one. It ran until
+ // the marker after it, which can be an END marker.
+ const currentIndex = withIntro.length - 1;
+ const lastIndex = withIntro
+ .slice(0, currentIndex)
+ .findLastIndex((t) => t.type === "start");
+
+ return {
+ ...withIntro[currentIndex]!,
+ lastTopic:
+ lastIndex === -1
+ ? null
+ : {
+ label: withIntro[lastIndex]!.label,
+ ms: Math.max(
+ withIntro[lastIndex + 1]!.startedAt -
+ withIntro[lastIndex]!.startedAt,
+ 0
+ ),
+ },
+ };
+}
diff --git a/src/utils/twitch-server.ts b/src/utils/twitch-server.ts
index 64000af..73abc3d 100644
--- a/src/utils/twitch-server.ts
+++ b/src/utils/twitch-server.ts
@@ -1,5 +1,5 @@
import { clerkClient } from "@clerk/nextjs/server";
-import { parseMarkerLabel, parseRewind } from "./markers";
+import { getLiveTopicFromMarkers, type LiveTopic } from "./markers";
export const generateTwitchRequestHeaders = (accessToken: string) => {
const headers = new Headers();
@@ -116,13 +116,6 @@ export const getTwitchTokenFromClerk = async (clerkUserId: string) => {
return token;
};
-export type LiveTopic = {
- label: string;
- type: "start" | "end";
- // Unix ms. Includes any "-2" style rewind on the marker.
- startedAt: number;
-};
-
type TwitchMarkersResponse = {
data?: { videos?: { markers?: VOD["markers"] }[] }[];
pagination?: { cursor?: string };
@@ -176,33 +169,8 @@ export const getLiveTopic = async (
if (!cursor) break;
}
- const streamStart = Date.parse(stream.started_at);
-
- // The current topic is the last marker placed on this stream. Offset
- // markers are not topics. Markers from before the stream started are from
- // an older VOD, in case Twitch has not made one for this stream yet.
- const topics = markers
- .filter((marker) => Date.parse(marker.created_at) >= streamStart)
- .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))
- .map((marker) => {
- const { rewindSeconds, description } = parseRewind(marker.description);
- return {
- ...parseMarkerLabel(description),
- // A "-2" rewind only moves the timer start, not which topic is current
- startedAt: Math.max(
- Date.parse(marker.created_at) - rewindSeconds * 1000,
- streamStart
- ),
- };
- })
- .filter((topic): topic is LiveTopic => topic.type !== "offset");
-
- // Same "Intro" fallback as the VOD page
- return (
- topics[topics.length - 1] ?? {
- label: "Intro",
- type: "start",
- startedAt: streamStart,
- }
- );
+ return getLiveTopicFromMarkers({
+ markers,
+ streamStart: Date.parse(stream.started_at),
+ });
};