Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/app/embed/[username]/live-topic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -47,12 +49,23 @@ export const LiveTopicView = (
</div>
<div
className="font-mono font-bold tabular-nums leading-none"
style={{ fontSize: "min(18vw, 40vh)" }}
style={{ fontSize: "min(18vw, 32vh)" }}
// Server and client clocks can be a second apart on first render
suppressHydrationWarning
>
{formatElapsed(now - props.topic.startedAt)}
</div>
{props.topic.lastTopic && (
// Duration first, so a narrow view cuts off the name, not the time
<div
className="font-mono truncate tabular-nums leading-tight"
style={{ fontSize: "min(6vw, 8vh)" }}
>
Last topic: {formatElapsed(props.topic.lastTopic.ms)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{props.topic.lastTopic.label.length <= MAX_LAST_TOPIC_CHARS &&
` (${props.topic.lastTopic.label})`}
</div>
)}
</>
) : (
<div style={{ fontSize: "min(9vw, 18vh)" }}>{props.status}</div>
Expand Down
36 changes: 36 additions & 0 deletions src/utils/markers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildSegments,
formatSeconds,
getLiveTopicFromMarkers,
parseMarkerLabel,
parseOffsetValue,
parseRewind,
Expand Down Expand Up @@ -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 });
});
});
71 changes: 71 additions & 0 deletions src/utils/markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LiveTopic, "lastTopic"> => 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
),
},
};
}
42 changes: 5 additions & 37 deletions src/utils/twitch-server.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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),
});
};
Loading