Load chat data in restreamer

thrimbletrimmer-solid
ElementalAlchemist 1 week ago
parent 8df629a1be
commit 14690ee406

@ -0,0 +1,95 @@
import { StreamVideoInfo } from "./streamInfo";
import { HLSProvider } from "vidstack";
import { DateTime } from "luxon";
import { Fragment } from "hls.js";
import { wubloaderTimeFromDateTime } from "./convertTime";
export interface ChatMessage {
command: string;
host: string;
params: string[];
receivers: { [node: string]: number };
sender: string;
tags: { [key: string]: string };
time: number;
time_range: number;
user: string;
}
export interface ChatMessageData {
message: ChatMessage;
when: DateTime;
whenSeconds: number;
whenDisplay: string;
}
export async function chatData(
streamInfo: StreamVideoInfo,
fragments: Fragment[],
): Promise<ChatMessageData[]> {
const streamName = streamInfo.streamName;
const streamStartTime = streamInfo.streamStartTime;
const streamEndTime = streamInfo.streamEndTime;
if (!streamEndTime) {
return [];
}
const startWubloaderTime = wubloaderTimeFromDateTime(streamStartTime);
const endWubloaderTime = wubloaderTimeFromDateTime(streamEndTime);
const params = new URLSearchParams({ start: startWubloaderTime, end: endWubloaderTime });
const chatResponse = await fetch(`/${streamName}/chat.json?${params}`);
if (!chatResponse.ok) {
return [];
}
const chatMessages: ChatMessage[] = await chatResponse.json();
if (!fragments || fragments.length === 0) {
return [];
}
let currentFragmentIndex = 0;
let currentFragmentStartTime = DateTime.fromISO(fragments[0].rawProgramDateTime!)!;
const chatData: ChatMessageData[] = [];
for (const chatMessage of chatMessages) {
if (
chatMessage.command !== "PRIVMSG" &&
chatMessage.command !== "CLEARMSG" &&
chatMessage.command !== "CLEARCHAT" &&
chatMessage.command !== "USERNOTICE"
) {
continue;
}
const when = DateTime.fromSeconds(chatMessage.time);
while (
currentFragmentIndex < fragments.length - 1 &&
currentFragmentStartTime.plus({ seconds: fragments[currentFragmentIndex].duration }) <= when
) {
currentFragmentIndex += 1;
currentFragmentStartTime = DateTime.fromISO(
fragments[currentFragmentIndex].rawProgramDateTime!,
);
}
const messageTimeOffset = when.diff(currentFragmentStartTime).seconds;
const messageVideoTime = fragments[currentFragmentIndex].start + messageTimeOffset;
chatData.push({
message: chatMessage,
when: when,
whenSeconds: messageVideoTime,
whenDisplay: formatDisplayTime(messageVideoTime),
});
}
return chatData;
}
function formatDisplayTime(timeSeconds: number): string {
const hours = Math.floor(timeSeconds / 3600);
const minutes = (Math.floor(timeSeconds / 60) % 60).toString().padStart(2, "0");
const seconds = Math.floor(timeSeconds % 60)
.toString()
.padStart(2, "0");
const milliseconds = Math.floor(timeSeconds * 1000)
.toString()
.padStart(3, "0");
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
}

@ -66,11 +66,11 @@ export function dateTimeFromTimeAgo(timeAgo: string): DateTime | null {
const mathObj = {}; const mathObj = {};
while (parts.length > 0) { while (parts.length > 0) {
const nextPart = parts.pop(); const nextPart = parts.pop()!;
if (properties.length === 0) { if (properties.length === 0) {
return null; return null;
} }
const nextProp = properties.pop(); const nextProp = properties.pop()!;
const partNumber = +nextPart; const partNumber = +nextPart;
if (isNaN(partNumber)) { if (isNaN(partNumber)) {
return null; return null;
@ -102,25 +102,18 @@ export function timeAgoFromDateTime(dateTime: DateTime): string {
return `${negative}${hours}:${minutesString}:${secondsString}`; return `${negative}${hours}:${minutesString}:${secondsString}`;
} }
export function dateTimeFromVideoPlayerTime( export function dateTimeFromVideoPlayerTime(fragments: Fragment[], videoTime: number): DateTime | null {
videoProvider: HLSProvider,
videoTime: number,
): DateTime | null {
// We do a little bit of cheating our way into private data. The standard way of getting this involves
// handling events and capturing and saving a fragments array from it, which seems overly cumbersome.
const fragments = (videoProvider.instance as any).latencyController.levelDetails
.fragments as Fragment[];
let fragmentStartTime: number | undefined = undefined; let fragmentStartTime: number | undefined = undefined;
let fragmentStartISOTime: string | undefined = undefined; let fragmentStartISOTime: string | undefined = undefined;
for (const fragment of fragments) { for (const fragment of fragments) {
const fragmentEndTime = fragment.start + fragment.duration; const fragmentEndTime = fragment.start + fragment.duration;
if (videoTime >= fragment.start && videoTime < fragmentEndTime) { if (videoTime >= fragment.start && videoTime < fragmentEndTime) {
fragmentStartTime = fragment.start; fragmentStartTime = fragment.start;
fragmentStartISOTime = fragment.rawProgramDateTime; fragmentStartISOTime = fragment.rawProgramDateTime!;
break; break;
} }
} }
if (fragmentStartISOTime === undefined) { if (fragmentStartTime === undefined || fragmentStartISOTime === undefined) {
return null; return null;
} }
const wubloaderTime = DateTime.fromISO(fragmentStartISOTime); const wubloaderTime = DateTime.fromISO(fragmentStartISOTime);

@ -0,0 +1,7 @@
import { DateTime } from "luxon";
export class StreamVideoInfo {
streamName: string;
streamStartTime: DateTime;
streamEndTime: DateTime | null;
}

@ -23,6 +23,7 @@ import styles from "./video.module.scss";
import "./video.scss"; import "./video.scss";
import { HLSProvider } from "vidstack"; import { HLSProvider } from "vidstack";
import { MediaPlayerElement } from "vidstack/elements"; import { MediaPlayerElement } from "vidstack/elements";
import { StreamVideoInfo } from "./streamInfo";
import "vidstack/icons"; import "vidstack/icons";
import "vidstack/player/styles/default/theme.css"; import "vidstack/player/styles/default/theme.css";
@ -35,12 +36,6 @@ export const VIDEO_FRAMES_PER_SECOND = 30;
export const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4, 8]; export const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4, 8];
export class StreamVideoInfo {
streamName: string;
streamStartTime: DateTime;
streamEndTime: DateTime | null;
}
export interface StreamTimeSettingsProps { export interface StreamTimeSettingsProps {
busStartTime: Accessor<DateTime>; busStartTime: Accessor<DateTime>;
streamVideoInfo: Accessor<StreamVideoInfo>; streamVideoInfo: Accessor<StreamVideoInfo>;
@ -62,7 +57,7 @@ export const StreamTimeSettings: Component<StreamTimeSettingsProps> = (props) =>
const streamName = formData.get("stream") as string; const streamName = formData.get("stream") as string;
const startTimeEntered = formData.get("start-time") as string; const startTimeEntered = formData.get("start-time") as string;
const endTimeEntered = formData.get("end-time") as string; const endTimeEntered = formData.get("end-time") as string;
const timeType = +formData.get("time-type") as TimeType; const timeType = +formData.get("time-type")! as TimeType;
let startTime: DateTime | null = null; let startTime: DateTime | null = null;
let endTime: DateTime | null = null; let endTime: DateTime | null = null;

@ -23,3 +23,8 @@
.videoLinks a { .videoLinks a {
margin: 2px; margin: 2px;
} }
.chatContainer {
height: 40vh;
overflow-y: auto;
}

@ -5,11 +5,13 @@ import {
createResource, createResource,
createSignal, createSignal,
For, For,
onMount,
Setter, Setter,
Show, Show,
Suspense, Suspense,
} from "solid-js"; } from "solid-js";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Fragment } from "hls.js";
import { HLSProvider } from "vidstack"; import { HLSProvider } from "vidstack";
import { MediaPlayerElement } from "vidstack/elements"; import { MediaPlayerElement } from "vidstack/elements";
import styles from "./Restreamer.module.scss"; import styles from "./Restreamer.module.scss";
@ -18,12 +20,9 @@ import {
dateTimeFromWubloaderTime, dateTimeFromWubloaderTime,
wubloaderTimeFromDateTime, wubloaderTimeFromDateTime,
} from "../common/convertTime"; } from "../common/convertTime";
import { import { StreamVideoInfo } from "../common/streamInfo";
KeyboardShortcuts, import { KeyboardShortcuts, StreamTimeSettings, VideoPlayer } from "../common/video";
StreamTimeSettings, import { chatData } from "../common/chat";
StreamVideoInfo,
VideoPlayer,
} from "../common/video";
export interface DefaultsData { export interface DefaultsData {
video_channel: string; video_channel: string;
@ -73,7 +72,7 @@ export const Restreamer: Component = () => {
<Suspense> <Suspense>
<Show when={defaultsData()}> <Show when={defaultsData()}>
<RestreamerWithDefaults <RestreamerWithDefaults
defaults={defaultsData()} defaults={defaultsData()!}
errorList={pageErrors} errorList={pageErrors}
setErrorList={setPageErrors} setErrorList={setPageErrors}
/> />
@ -90,9 +89,11 @@ interface RestreamerDefaultProps {
} }
const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => { const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
const [busStartTime, setBusStartTime] = createSignal<DateTime>( const busStartTimeDefault = dateTimeFromWubloaderTime(props.defaults.bustime_start);
dateTimeFromWubloaderTime(props.defaults.bustime_start), if (!busStartTimeDefault) {
); return <></>;
}
const [busStartTime, setBusStartTime] = createSignal<DateTime>(busStartTimeDefault);
const [streamVideoInfo, setStreamVideoInfo] = createSignal<StreamVideoInfo>({ const [streamVideoInfo, setStreamVideoInfo] = createSignal<StreamVideoInfo>({
streamName: props.defaults.video_channel, streamName: props.defaults.video_channel,
streamStartTime: DateTime.utc().minus({ minutes: 10 }), streamStartTime: DateTime.utc().minus({ minutes: 10 }),
@ -100,6 +101,16 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
}); });
const [playerTime, setPlayerTime] = createSignal<number>(0); const [playerTime, setPlayerTime] = createSignal<number>(0);
const [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>(); const [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>();
const [videoFragments, setVideoFragments] = createSignal<Fragment[]>([]);
onMount(() => {
const player = mediaPlayer();
if (player) {
player.addEventListener("hls-level-loaded", (event) => {
setVideoFragments(event.detail.details.fragments);
});
}
});
const videoURL = () => { const videoURL = () => {
const streamInfo = streamVideoInfo(); const streamInfo = streamVideoInfo();
@ -130,13 +141,12 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
const downloadFrameURL = () => { const downloadFrameURL = () => {
const streamInfo = streamVideoInfo(); const streamInfo = streamVideoInfo();
const player = mediaPlayer(); const fragments = videoFragments();
const videoTime = playerTime(); const videoTime = playerTime();
const provider = player.provider as HLSProvider; if (!fragments || fragments.length === 0) {
if (!provider) {
return ""; return "";
} }
const currentTime = dateTimeFromVideoPlayerTime(provider, videoTime); const currentTime = dateTimeFromVideoPlayerTime(fragments, videoTime);
if (currentTime === null) { if (currentTime === null) {
return ""; return "";
} }
@ -144,6 +154,22 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
return `/frame/${streamInfo.streamName}/source.png?timestamp=${wubloaderTime}`; return `/frame/${streamInfo.streamName}/source.png?timestamp=${wubloaderTime}`;
}; };
const streamDataAndFragments = () => {
const streamInfo = streamVideoInfo();
const fragments = videoFragments();
if (!fragments || fragments.length === 0) {
return null;
}
return {
streamInfo: streamInfo,
fragments: fragments
};
};
const [chatMessages] = createResource(streamDataAndFragments, async () => {
const { streamInfo, fragments } = streamDataAndFragments()!;
return await chatData(streamInfo, fragments);
});
return ( return (
<> <>
<StreamTimeSettings <StreamTimeSettings
@ -157,13 +183,14 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
<VideoPlayer <VideoPlayer
src={videoURL} src={videoURL}
setPlayerTime={setPlayerTime} setPlayerTime={setPlayerTime}
mediaPlayer={mediaPlayer} mediaPlayer={mediaPlayer as Accessor<MediaPlayerElement>}
setMediaPlayer={setMediaPlayer} setMediaPlayer={setMediaPlayer as Setter<MediaPlayerElement>}
/> />
<div class={styles.videoLinks}> <div class={styles.videoLinks}>
<a href={downloadVideoURL()}>Download Video</a> <a href={downloadVideoURL()}>Download Video</a>
<a href={downloadFrameURL()}>Download Current Frame as Image</a> <a href={downloadFrameURL()}>Download Current Frame as Image</a>
</div> </div>
<div class={styles.chatContainer}></div>
</> </>
); );
}; };

@ -9,6 +9,7 @@
"jsxImportSource": "solid-js", "jsxImportSource": "solid-js",
"types": ["vidstack/solid", "vite/client"], "types": ["vidstack/solid", "vite/client"],
"noEmit": true, "noEmit": true,
"isolatedModules": true "isolatedModules": true,
"strictNullChecks": true
} }
} }

Loading…
Cancel
Save