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), + }); };