diff --git a/thrimbletrimmer/src/common/chat.tsx b/thrimbletrimmer/src/common/chat.tsx new file mode 100644 index 0000000..5c8e328 --- /dev/null +++ b/thrimbletrimmer/src/common/chat.tsx @@ -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 { + 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}`; +} diff --git a/thrimbletrimmer/src/common/convertTime.tsx b/thrimbletrimmer/src/common/convertTime.tsx index 9a59cb8..7f8a183 100644 --- a/thrimbletrimmer/src/common/convertTime.tsx +++ b/thrimbletrimmer/src/common/convertTime.tsx @@ -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); diff --git a/thrimbletrimmer/src/common/streamInfo.tsx b/thrimbletrimmer/src/common/streamInfo.tsx new file mode 100644 index 0000000..f0caef5 --- /dev/null +++ b/thrimbletrimmer/src/common/streamInfo.tsx @@ -0,0 +1,7 @@ +import { DateTime } from "luxon"; + +export class StreamVideoInfo { + streamName: string; + streamStartTime: DateTime; + streamEndTime: DateTime | null; +} diff --git a/thrimbletrimmer/src/common/video.tsx b/thrimbletrimmer/src/common/video.tsx index 7a8ed53..452bcbf 100644 --- a/thrimbletrimmer/src/common/video.tsx +++ b/thrimbletrimmer/src/common/video.tsx @@ -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; streamVideoInfo: Accessor; @@ -62,7 +57,7 @@ export const StreamTimeSettings: Component = (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; diff --git a/thrimbletrimmer/src/restreamer/Restreamer.module.scss b/thrimbletrimmer/src/restreamer/Restreamer.module.scss index 506cba0..162be21 100644 --- a/thrimbletrimmer/src/restreamer/Restreamer.module.scss +++ b/thrimbletrimmer/src/restreamer/Restreamer.module.scss @@ -23,3 +23,8 @@ .videoLinks a { margin: 2px; } + +.chatContainer { + height: 40vh; + overflow-y: auto; +} diff --git a/thrimbletrimmer/src/restreamer/Restreamer.tsx b/thrimbletrimmer/src/restreamer/Restreamer.tsx index 9eceecb..28615bd 100644 --- a/thrimbletrimmer/src/restreamer/Restreamer.tsx +++ b/thrimbletrimmer/src/restreamer/Restreamer.tsx @@ -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 = () => { @@ -90,9 +89,11 @@ interface RestreamerDefaultProps { } const RestreamerWithDefaults: Component = (props) => { - const [busStartTime, setBusStartTime] = createSignal( - dateTimeFromWubloaderTime(props.defaults.bustime_start), - ); + const busStartTimeDefault = dateTimeFromWubloaderTime(props.defaults.bustime_start); + if (!busStartTimeDefault) { + return <>; + } + const [busStartTime, setBusStartTime] = createSignal(busStartTimeDefault); const [streamVideoInfo, setStreamVideoInfo] = createSignal({ streamName: props.defaults.video_channel, streamStartTime: DateTime.utc().minus({ minutes: 10 }), @@ -100,6 +101,16 @@ const RestreamerWithDefaults: Component = (props) => { }); const [playerTime, setPlayerTime] = createSignal(0); const [mediaPlayer, setMediaPlayer] = createSignal(); + const [videoFragments, setVideoFragments] = createSignal([]); + + 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 = (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 = (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 ( <> = (props) => { } + setMediaPlayer={setMediaPlayer as Setter} /> +
); }; diff --git a/thrimbletrimmer/tsconfig.json b/thrimbletrimmer/tsconfig.json index 47dd96e..5c831cb 100644 --- a/thrimbletrimmer/tsconfig.json +++ b/thrimbletrimmer/tsconfig.json @@ -9,6 +9,7 @@ "jsxImportSource": "solid-js", "types": ["vidstack/solid", "vite/client"], "noEmit": true, - "isolatedModules": true + "isolatedModules": true, + "strictNullChecks": true } }