Skip to content

Commit

Permalink
Auto filter end tags, add 1 click offset (#75)
Browse files Browse the repository at this point in the history
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
  • Loading branch information
t3dotgg and coderabbitai[bot] authored Oct 17, 2024
1 parent 404d904 commit c7a00eb
Showing 1 changed file with 176 additions and 57 deletions.
233 changes: 176 additions & 57 deletions src/app/(core)/v/[slug]/player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,130 @@ import type { VOD } from "~/utils/twitch-server";
import { Player } from "~/utils/types/twitch-player";
dayjs.extend(duration);

function parseOffsetValue(value: string): number | undefined {
// if there are no colons, assume its seconds
if (/^\d+$/.test(value)) return parseInt(value, 10);

// Supports HH:MM:SS, MM:SS, SS
// If it's not in the format, return undefined
if (!/^([0-5]?[0-9]:){0,2}[0-5][0-9]$/.test(value)) return undefined;

return value
.split(":")
.reduce((acc, cur) => (acc = acc * 60 + parseInt(cur, 10)), 0);
}

// Example marker labels
// "Talking about Chrome" - no metadata, just label
// "START: Talking about Chrome" - start marker, same as above
// "END: Talking about Chrome" - end marker, tagged accordingly so it can be filtered out
// "End of Talking about Chrome" - end marker, same as above
// "OFFSET 00:40:31" - offset marker

const START_LABELS = ["START:", "START OF", "START"];
const END_LABELS = ["END:", "END OF", "END"];
const OFFSET_LABELS = ["OFFSET", "OFFSET:"];
const LABELS = [...START_LABELS, ...END_LABELS, ...OFFSET_LABELS];

function parseMetadataFromMarker(marker: string) {
for (const tl of START_LABELS) {
if (marker.toLowerCase().startsWith(tl.toLowerCase())) {
return {
type: "start",
label: marker.slice(tl.length).trim(),
};
}
}

for (const tl of END_LABELS) {
if (marker.toLowerCase().startsWith(tl.toLowerCase())) {
return {
type: "end",
label: marker.slice(tl.length).trim(),
};
}
}

for (const tl of OFFSET_LABELS) {
if (marker.toLowerCase().startsWith(tl.toLowerCase())) {
return {
type: "offset",
label: marker.slice(tl.length).trim().replaceAll("-", ":"),
};
}
}

return {
type: "start",
label: marker,
};
}

function parseMarkers(props: { vod: VOD; offset?: { totalSeconds: number } }) {
const videoDuration = getDurationFromTwitchFormat(
(props.vod as any)?.duration ?? "0h0m0s"
);

const mockedMarkers = [
{ position_seconds: 0, id: "start", description: "Intro" },
...props.vod.markers,
];

const OFFSET = props.offset?.totalSeconds ?? 0;

const taggedMarkers = mockedMarkers.map((marker, id) => {
let endTime =
(mockedMarkers[id + 1]?.position_seconds ??
(videoDuration as duration.Duration)?.asSeconds?.()) - OFFSET;

endTime += EXPORT_BUFFER;

if (endTime < 0) endTime = 1;

const startTime = Math.max(
marker.position_seconds - OFFSET - EXPORT_BUFFER,
0
);

const duration = dayjs
.duration(endTime * 1000 - startTime * 1000)
.format("HH:mm:ss");

const taggedDescription = parseMetadataFromMarker(marker.description);

return {
startTime,
endTime,
duration,
...taggedDescription,
};
});

const filteredMarkers = taggedMarkers.filter(
(m) => m.type === "start" || m.type === "offset"
);

const ytChapters = filteredMarkers.reduce((acc, marker) => {
const timeStr = dayjs
.duration(marker.startTime * 1000)
.format("HH:mm:ss");
return `${acc}${timeStr} ${marker.label}\n`;
}, "");

const csv = filteredMarkers
.map((marker) => {
return `${marker.startTime},${marker.endTime},${marker.label}`;
})
.join("\n");

return {
taggedMarkers,
filteredMarkers,
ytChapters,
csv,
};
}

// Converts "8h32m12s" format into dayjs duration
// I absolutely hate this function and would love ANYTHING better
const getDurationFromTwitchFormat = (input: string) => {
Expand Down Expand Up @@ -81,62 +205,25 @@ export const VodPlayer = (props: { id: string; vod: VOD }) => {
return cleanup;
}, [props.id]);

const videoDuration = props.vod
? getDurationFromTwitchFormat((props.vod as any)?.duration ?? "0h0m0s")
: "n/a";

const mockedMarkers = [
{ position_seconds: 0, id: "start", description: "Intro" },
...props.vod.markers,
];

const [offset, setOffset] = useState<{
presentational: string;
totalSeconds: number;
}>({
presentational: "0",
totalSeconds: 0,
});
const csv = mockedMarkers.flatMap((marker, id) => {
let endTime =
(mockedMarkers[id + 1]?.position_seconds ??
(videoDuration as duration.Duration)?.asSeconds?.()) -
offset.totalSeconds;

endTime += EXPORT_BUFFER;

if (endTime < 0) endTime = 1;

const startTime = Math.max(
marker.position_seconds - offset.totalSeconds - EXPORT_BUFFER,
0
);

if (marker.description.toLowerCase().startsWith("end of")) return [];

return [`${startTime},${endTime},${marker.description.replace(",", "")}`];
});

const ytChapters = mockedMarkers.reduce((acc, marker) => {
const startTime = new Date(
(marker.position_seconds - offset.totalSeconds) * 1000
);
const timeStr = startTime.toISOString().substr(11, 8);
return `${acc}${marker.description} - ${timeStr}\n`;
}, "");
const parsedMarkers = useMemo(() => {
if (!props.vod) return;

function parseOffsetValue(value: string): number | undefined {
// if there are no colons, assume its seconds
if (/^\d+$/.test(value)) return parseInt(value, 10);
return parseMarkers({
vod: props.vod,
offset: offset,
});
}, [props, offset]);

// Supports HH:MM:SS, MM:SS, SS
// If it's not in the format, return undefined
if (!/^([0-5]?[0-9]:){0,2}[0-5][0-9]$/.test(value)) return undefined;

return value
.split(":")
.reduce((acc, cur) => (acc = acc * 60 + parseInt(cur, 10)), 0);
}
if (!parsedMarkers) return null;
const { taggedMarkers, filteredMarkers, ytChapters, csv } = parsedMarkers;

return (
<div className="grid min-h-0 flex-1 grid-rows-3 items-start gap-4 overflow-y-hidden p-4 sm:grid-cols-3 sm:grid-rows-1 sm:gap-8 sm:p-8">
Expand Down Expand Up @@ -168,9 +255,7 @@ export const VodPlayer = (props: { id: string; vod: VOD }) => {
</Button>
<a
className="relative inline-flex items-center rounded border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-gray-750 hover:text-gray-100"
href={`data:text/csv;charset=utf-8,${encodeURIComponent(
csv.join("\n")
)}`}
href={`data:text/csv;charset=utf-8,${encodeURIComponent(csv)}`}
{...{
download: `${props.vod?.created_at} VOD MARKERS${
offset.totalSeconds ? ` - ${offset.totalSeconds}s` : ""
Expand Down Expand Up @@ -208,22 +293,56 @@ export const VodPlayer = (props: { id: string; vod: VOD }) => {

{props.vod && (
<ul className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto rounded-lg border border-gray-950/25 bg-gray-950/25 p-2 shadow-inner">
{mockedMarkers.map((marker, index) => {
{filteredMarkers.map((marker, index) => {
return (
<li key={marker.id}>
<li key={marker.startTime}>
<button
className="w-full"
onClick={() => {
player?.seek(marker.position_seconds);
if (marker.type === "start") {
player?.seek(marker.startTime);
}

if (marker.type === "offset") {
const offset = parseOffsetValue(marker.label);
if (offset) {
setOffset({
presentational: marker.label,
totalSeconds: offset,
});
}
}
}}
>
<Card className="flex animate-fade-in-down flex-col gap-4 p-4 text-left">
<div className="break-words">{marker.description}</div>
<div className="flex justify-between break-words">
{`${marker.label}`}
<div className="flex flex-col gap-0.5">
<div className="font-mono text-right text-xs text-gray-400">
{`${dayjs
.duration(marker.startTime * 1000)
.format("HH:mm:ss")} - ${dayjs
.duration(marker.endTime * 1000)
.format("HH:mm:ss")}`}
</div>
<div className="font-mono text-right text-xs text-gray-400">
{`(${marker.duration})`}
</div>
</div>
</div>
<div className="flex items-center justify-between text-gray-300">
<div className="text-sm">
{dayjs
.duration(marker.position_seconds * 1000)
.format("HH:mm:ss")}
<div
className={`-m-2 rounded-full px-2 py-1 text-sm font-bold ${
marker.type === "start"
? "bg-green-800 text-white"
: marker.type === "end"
? "bg-red-800 text-white"
: marker.type === "offset"
? "bg-blue-800 text-white"
: "bg-gray-800 text-white"
}`}
>
{marker.type}
</div>
</div>
</Card>
Expand Down

0 comments on commit c7a00eb

Please sign in to comment.