From d309345feb9c2d36cd68dd1fcadb6c1bdcacfa0f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 18:14:01 -0700 Subject: [PATCH 1/3] feat(embed): show how long the last topic ran Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/embed/[username]/live-topic.tsx | 10 +++- src/utils/markers.test.ts | 34 ++++++++++++ src/utils/markers.ts | 69 +++++++++++++++++++++++++ src/utils/twitch-server.ts | 42 ++------------- 4 files changed, 117 insertions(+), 38 deletions(-) diff --git a/src/app/embed/[username]/live-topic.tsx b/src/app/embed/[username]/live-topic.tsx index 099fda3..fde2fc7 100644 --- a/src/app/embed/[username]/live-topic.tsx +++ b/src/app/embed/[username]/live-topic.tsx @@ -2,7 +2,7 @@ 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; @@ -53,6 +53,14 @@ export const LiveTopicView = ( > {formatElapsed(now - props.topic.startedAt)} + {props.topic.lastTopicMs !== null && ( +
+ Last topic: {formatElapsed(props.topic.lastTopicMs)} +
+ )} ) : (
{props.status}
diff --git a/src/utils/markers.test.ts b/src/utils/markers.test.ts index cb1e1fd..00d72cb 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,36 @@ 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 lastTopicMs = ( + markers: { created_at: string; description: string }[] + ) => getLiveTopicFromMarkers({ markers, streamStart }).lastTopicMs; + + it("gives how long the topic before the current one ran", () => { + expect(lastTopicMs([liveMarker(0, "Chrome"), liveMarker(12, "Rust")])).toBe( + 12 * 60_000 + ); + }); + + it("counts the Intro as the first topic", () => { + expect(lastTopicMs([liveMarker(5, "Chrome")])).toBe(5 * 60_000); + expect(lastTopicMs([])).toBeNull(); + }); + + it("ends the last topic at an END marker", () => { + expect( + lastTopicMs([ + liveMarker(0, "Chrome"), + liveMarker(10, "END: Chrome"), + liveMarker(15, "Rust"), + ]) + ).toBe(10 * 60_000); + }); +}); diff --git a/src/utils/markers.ts b/src/utils/markers.ts index 81cf996..515fb50 100644 --- a/src/utils/markers.ts +++ b/src/utils/markers.ts @@ -190,3 +190,72 @@ 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; + // How long the topic before this one ran, in ms. Null when there is none. + lastTopicMs: 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]!, + lastTopicMs: + lastIndex === -1 + ? null + : 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), + }); }; From 54cde62a05c1955c4b51fd86a1869139194bb3e7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 18:22:23 -0700 Subject: [PATCH 2/3] fix(embed): keep two-line topics visible in wide views Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/embed/[username]/live-topic.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/embed/[username]/live-topic.tsx b/src/app/embed/[username]/live-topic.tsx index fde2fc7..2b46fa1 100644 --- a/src/app/embed/[username]/live-topic.tsx +++ b/src/app/embed/[username]/live-topic.tsx @@ -47,7 +47,7 @@ export const LiveTopicView = (
@@ -56,7 +56,7 @@ export const LiveTopicView = ( {props.topic.lastTopicMs !== null && (
Last topic: {formatElapsed(props.topic.lastTopicMs)}
From 670b50f21037f34731e4e1ea20af20423e193b3f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 18:30:13 -0700 Subject: [PATCH 3/3] feat(embed): show the last topic name when it is short Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/embed/[username]/live-topic.tsx | 11 ++++++++--- src/utils/markers.test.ts | 24 +++++++++++++----------- src/utils/markers.ts | 22 ++++++++++++---------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/app/embed/[username]/live-topic.tsx b/src/app/embed/[username]/live-topic.tsx index 2b46fa1..565ea41 100644 --- a/src/app/embed/[username]/live-topic.tsx +++ b/src/app/embed/[username]/live-topic.tsx @@ -5,6 +5,8 @@ import { useEffect, useState } from "react"; 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) => { @@ -53,12 +55,15 @@ export const LiveTopicView = ( > {formatElapsed(now - props.topic.startedAt)}
- {props.topic.lastTopicMs !== null && ( + {props.topic.lastTopic && ( + // Duration first, so a narrow view cuts off the name, not the time
- Last topic: {formatElapsed(props.topic.lastTopicMs)} + Last topic: {formatElapsed(props.topic.lastTopic.ms)} + {props.topic.lastTopic.label.length <= MAX_LAST_TOPIC_CHARS && + ` (${props.topic.lastTopic.label})`}
)} diff --git a/src/utils/markers.test.ts b/src/utils/markers.test.ts index 00d72cb..95e0e88 100644 --- a/src/utils/markers.test.ts +++ b/src/utils/markers.test.ts @@ -268,28 +268,30 @@ describe("getLiveTopicFromMarkers", () => { created_at: new Date(streamStart + minutes * 60_000).toISOString(), description, }); - const lastTopicMs = ( - markers: { created_at: string; description: string }[] - ) => getLiveTopicFromMarkers({ markers, streamStart }).lastTopicMs; + const lastTopic = (markers: { created_at: string; description: string }[]) => + getLiveTopicFromMarkers({ markers, streamStart }).lastTopic; - it("gives how long the topic before the current one ran", () => { - expect(lastTopicMs([liveMarker(0, "Chrome"), liveMarker(12, "Rust")])).toBe( - 12 * 60_000 - ); + 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(lastTopicMs([liveMarker(5, "Chrome")])).toBe(5 * 60_000); - expect(lastTopicMs([])).toBeNull(); + 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( - lastTopicMs([ + lastTopic([ liveMarker(0, "Chrome"), liveMarker(10, "END: Chrome"), liveMarker(15, "Rust"), ]) - ).toBe(10 * 60_000); + ).toEqual({ label: "Chrome", ms: 10 * 60_000 }); }); }); diff --git a/src/utils/markers.ts b/src/utils/markers.ts index 515fb50..7846932 100644 --- a/src/utils/markers.ts +++ b/src/utils/markers.ts @@ -196,8 +196,8 @@ export type LiveTopic = { type: "start" | "end"; // Unix ms. Includes any "-2" style rewind on the marker. startedAt: number; - // How long the topic before this one ran, in ms. Null when there is none. - lastTopicMs: number | null; + // 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. @@ -225,8 +225,7 @@ export function getLiveTopicFromMarkers(opts: { }; }) .filter( - (topic): topic is Omit => - topic.type !== "offset" + (topic): topic is Omit => topic.type !== "offset" ); // Same "Intro" fallback as the VOD page, unless a topic starts with the stream @@ -249,13 +248,16 @@ export function getLiveTopicFromMarkers(opts: { return { ...withIntro[currentIndex]!, - lastTopicMs: + lastTopic: lastIndex === -1 ? null - : Math.max( - withIntro[lastIndex + 1]!.startedAt - - withIntro[lastIndex]!.startedAt, - 0 - ), + : { + label: withIntro[lastIndex]!.label, + ms: Math.max( + withIntro[lastIndex + 1]!.startedAt - + withIntro[lastIndex]!.startedAt, + 0 + ), + }, }; }