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 = {};
while (parts.length > 0) {
const nextPart = parts.pop();
const nextPart = parts.pop()!;
if (properties.length === 0) {
return null;
}
const nextProp = properties.pop();
const nextProp = properties.pop()!;
const partNumber = +nextPart;
if (isNaN(partNumber)) {
return null;
@ -102,25 +102,18 @@ export function timeAgoFromDateTime(dateTime: DateTime): string {
return `${negative}${hours}:${minutesString}:${secondsString}`;
}
export function dateTimeFromVideoPlayerTime(
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[];
export function dateTimeFromVideoPlayerTime(fragments: Fragment[], videoTime: number): DateTime | null {
let fragmentStartTime: number | undefined = undefined;
let fragmentStartISOTime: string | undefined = undefined;
for (const fragment of fragments) {
const fragmentEndTime = fragment.start + fragment.duration;
if (videoTime >= fragment.start && videoTime < fragmentEndTime) {
fragmentStartTime = fragment.start;
fragmentStartISOTime = fragment.rawProgramDateTime;
fragmentStartISOTime = fragment.rawProgramDateTime!;
break;
}
}
if (fragmentStartISOTime === undefined) {
if (fragmentStartTime === undefined || fragmentStartISOTime === undefined) {
return null;
}
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 { HLSProvider } from "vidstack";
import { MediaPlayerElement } from "vidstack/elements";
import { StreamVideoInfo } from "./streamInfo";
import "vidstack/icons";
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 class StreamVideoInfo {
streamName: string;
streamStartTime: DateTime;
streamEndTime: DateTime | null;
}
export interface StreamTimeSettingsProps {
busStartTime: Accessor<DateTime>;
streamVideoInfo: Accessor<StreamVideoInfo>;
@ -62,7 +57,7 @@ export const StreamTimeSettings: Component<StreamTimeSettingsProps> = (props) =>
const streamName = formData.get("stream") as string;
const startTimeEntered = formData.get("start-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 endTime: DateTime | null = null;

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

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

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

Loading…
Cancel
Save