Compare commits

..

8 Commits

Author SHA1 Message Date
ElementalAlchemist 92a8d1353b Fix keyboard shortcuts help visibility 7 days ago
ElementalAlchemist 5d37b26def Show fullscreen controls at the bottom of the video 7 days ago
ElementalAlchemist cbc55c7345 Keep media controls visible 7 days ago
ElementalAlchemist 9c97b564c7 Replace manual events with vidstack media gestures 1 week ago
ElementalAlchemist 106cd21215 Chat autoscroll 1 week ago
ElementalAlchemist 213b4066ff Factor chat display into its own component 1 week ago
ElementalAlchemist bd866ba1bf Chat replay 1 week ago
ElementalAlchemist 14690ee406 Load chat data in restreamer 1 week ago

@ -0,0 +1,50 @@
.chatReplayMessage {
display: flex;
align-items: baseline;
gap: 10px;
}
.chatReplayMessageSystem {
color: #aaf;
}
.chatReplayMessageTime {
flex-basis: 110px;
color: #ccc;
text-align: right;
}
.chatReplayMessageText {
flex-basis: 100px;
flex-grow: 1;
}
.chatReplayMessageReply {
font-size: 80%;
a {
text-decoration: none;
}
}
.chatReplayMessageTextAction {
font-style: italic;
.chatReplayMessageReply:not(.chatReplayMessageTextAction) {
font-style: normal;
}
}
.chatReplayMessageEmote {
// The sizes here are based on Twitch's 1.0 emote size.
width: 28px;
height: 28px;
}
.chatReplayMessageCleared {
opacity: 0.5;
.chatReplayMessageText {
text-decoration: line-through;
}
}

@ -0,0 +1,425 @@
import { Accessor, Component, createResource, For, Index, JSX, Show, Suspense } from "solid-js";
import { StreamVideoInfo } from "./streamInfo";
import { DateTime } from "luxon";
import { Fragment } from "hls.js";
import { wubloaderTimeFromDateTime } from "./convertTime";
import styles from "./chat.module.scss";
export interface RawChatMessage {
command: string;
host: string;
params: string[];
receivers: { [node: string]: number };
sender: string;
tags: { [key: string]: string };
time: number;
time_range: number;
user: string;
}
export class ChatMessageData {
message: RawChatMessage;
messageID: string;
userID: string | null;
when: DateTime;
whenSeconds: number;
whenDisplay: string;
}
export class ChatLog {
messages: ChatMessageData[];
messagesByID: { [id: string]: ChatMessageData };
messagesBySender: { [username: string]: ChatMessageData[] };
clearMessages: { [messageID: string]: number };
clearUsers: { [username: string]: number[] };
public constructor(
messages: ChatMessageData[],
clearMessages: { [messageID: string]: number },
clearUsers: { [username: string]: number[] },
) {
const messagesByID: { [id: string]: ChatMessageData } = {};
const messagesBySender: { [username: string]: ChatMessageData[] } = {};
for (const message of messages) {
messagesByID[message.message.tags.id] = message;
if (!messagesBySender.hasOwnProperty(message.message.sender)) {
messagesBySender[message.message.sender] = [];
}
messagesBySender[message.message.sender].push(message);
}
return {
messages: messages,
messagesByID: messagesByID,
messagesBySender: messagesBySender,
clearMessages: clearMessages,
clearUsers: clearUsers,
};
}
static default = () => new ChatLog([], {}, {});
}
export async function chatData(
streamInfo: StreamVideoInfo,
fragments: Fragment[],
): Promise<ChatLog> {
const streamName = streamInfo.streamName;
const streamStartTime = streamInfo.streamStartTime;
const streamEndTime = streamInfo.streamEndTime;
if (!streamEndTime) {
return ChatLog.default();
}
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 ChatLog.default();
}
const chatMessages: RawChatMessage[] = await chatResponse.json();
if (!fragments || fragments.length === 0) {
return ChatLog.default();
}
let currentFragmentIndex = 0;
let currentFragmentStartTime = DateTime.fromISO(fragments[0].rawProgramDateTime!)!;
const chatData: ChatMessageData[] = [];
const clearMessages: { [messageID: string]: number } = {};
const clearUsers: { [userID: string]: number[] } = {};
for (const chatMessage of chatMessages) {
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).milliseconds / 1000;
const messageVideoTime = fragments[currentFragmentIndex].start + messageTimeOffset;
if (chatMessage.command === "PRIVMSG") {
chatData.push({
message: chatMessage,
messageID: chatMessage.tags["id"],
userID: chatMessage.tags["user-id"],
when: when,
whenSeconds: messageVideoTime,
whenDisplay: formatDisplayTime(messageVideoTime),
});
} else if (chatMessage.command === "USERNOTICE") {
chatData.push({
message: chatMessage,
messageID: chatMessage.tags["id"],
userID: null,
when: when,
whenSeconds: messageVideoTime,
whenDisplay: formatDisplayTime(messageVideoTime),
});
} else if (chatMessage.command === "CLEARMSG") {
const messageID = chatMessage.tags["target-msg-id"];
clearMessages[messageID] = messageVideoTime;
} else if (chatMessage.command === "CLEARCHAT") {
const userID = chatMessage.tags["target-user-id"];
if (!clearUsers.hasOwnProperty(userID)) {
clearUsers[userID] = [];
}
clearUsers[userID].push(messageVideoTime);
}
}
return new ChatLog(chatData, clearMessages, clearUsers);
}
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 % 1) * 1000)
.toString()
.padStart(3, "0");
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
}
export interface ChatDisplayProps {
streamInfo: StreamVideoInfo;
fragments: Accessor<Fragment[]>;
videoTime: Accessor<number>;
}
export const ChatDisplay: Component<ChatDisplayProps> = (props) => {
const streamDataAndFragments = () => {
const fragments = props.fragments();
if (!fragments || fragments.length === 0) {
return null;
}
return {
streamInfo: props.streamInfo,
fragments: fragments,
};
};
const [possibleChatLog] = createResource(streamDataAndFragments, async () => {
const { streamInfo, fragments } = streamDataAndFragments()!;
return await chatData(streamInfo, fragments);
});
const chatLog = () => {
const chatLogData = possibleChatLog();
if (chatLogData) {
return chatLogData;
}
return ChatLog.default();
};
return (
<Suspense>
<Index each={chatLog().messages}>
{(item: Accessor<ChatMessageData>, index: number) => {
const chatCommand = item().message.command;
if (chatCommand === "PRIVMSG") {
return (
<ChatMessage chatMessage={item()} chatLog={chatLog()} videoTime={props.videoTime} />
);
} else if (chatCommand === "USERNOTICE") {
return <SystemMessage chatMessage={item()} videoTime={props.videoTime} />;
} else {
return <></>;
}
}}
</Index>
</Suspense>
);
};
export interface ChatMessageProps {
chatMessage: ChatMessageData;
chatLog: ChatLog;
videoTime: Accessor<number>;
}
export const ChatMessage: Component<ChatMessageProps> = (props) => {
const message = props.chatMessage;
const displayChatMessage = () => props.videoTime() >= message.whenSeconds;
const mayClearMessage = props.chatLog.clearMessages.hasOwnProperty(message.messageID);
const mayClearUser =
message.userID &&
props.chatLog.clearUsers.hasOwnProperty(message.userID.toString()) &&
props.chatLog.clearUsers[message.userID].some((clearTime) => clearTime > message.whenSeconds);
const messageCleared = () => {
const messageClearTime = props.chatLog.clearMessages[message.messageID];
const videoTime = props.videoTime();
return videoTime >= messageClearTime;
};
const userCleared = () => {
if (message.userID) {
let userClearTime = 0;
for (const clearTime of props.chatLog.clearUsers[message.userID]) {
if (clearTime >= message.whenSeconds) {
userClearTime = clearTime;
break;
}
}
const videoTime = props.videoTime();
return videoTime >= userClearTime;
}
return false;
};
let clearMessageCheck = () => false;
if (mayClearMessage && mayClearUser) {
clearMessageCheck = () => messageCleared() || userCleared();
} else if (mayClearMessage) {
clearMessageCheck = messageCleared;
} else if (mayClearUser) {
clearMessageCheck = userCleared;
}
return (
<Show when={displayChatMessage()}>
<div
id={`chat-replay-message-${message.message.tags.id}`}
classList={(() => {
const classList: any = {};
classList[styles.chatReplayMessage] = true;
classList[styles.chatReplayMessageCleared] = clearMessageCheck();
return classList;
})()}
>
<div class={styles.chatReplayMessageTime}>{message.whenDisplay}</div>
<MessageSender chatMessage={message} />
<MessageText chatMessage={message} />
</div>
</Show>
);
};
export interface SystemMessageProps {
chatMessage: ChatMessageData;
videoTime: Accessor<number>;
}
export const SystemMessage: Component<SystemMessageProps> = (props) => {
const message = props.chatMessage;
const displaySystemMessage = () => props.videoTime() >= message.whenSeconds;
const systemMessage = () => {
const systemMsg = message.message.tags["system-msg"];
if (!systemMsg && message.message.tags["msg-id"] === "announcement") {
return "Announcement";
}
return systemMsg;
};
return (
<Show when={displaySystemMessage()}>
<div
id={`chat-replay-message-system-${message.message.tags.id}`}
class={styles.chatReplayMessage}
>
<div class={styles.chatReplayMessageTime}>{message.whenDisplay}</div>
<MessageSender chatMessage={message} />
<div class={`${styles.chatReplayMessageText} ${styles.chatReplayMessageSystem}`}>
{systemMessage()}
</div>
</div>
<Show when={message.message.params.length > 1}>
<div id={`chat-replay-message-${message.message.tags.id}`}>
<div class={styles.chatReplayMessageTime}></div>
<MessageSender chatMessage={message} />
<MessageText chatMessage={message} />
</div>
</Show>
</Show>
);
};
interface MessageSenderProps {
chatMessage: ChatMessageData;
}
const MessageSender: Component<MessageSenderProps> = (props) => {
const message = props.chatMessage.message;
const color = message.tags.hasOwnProperty("color") ? message.tags.color : "inherit";
return (
<div class={styles.chatReplayMessageSender} style={`color: ${color}`}>
{message.tags["display-name"]}
</div>
);
};
interface MessageTextProps {
chatMessage: ChatMessageData;
}
const MessageText: Component<MessageTextProps> = (props) => {
let chatMessageText = props.chatMessage.message.params[1];
const messageParts: JSX.Element[] = [];
let replyData: { id: string; user: string; message: string; isAction: boolean } | null = null;
if (props.chatMessage.message.tags.hasOwnProperty("reply-parent-msg-id")) {
const messageTags = props.chatMessage.message.tags;
let messageText = messageTags["reply-parent-msg-body"];
const isAction = messageText.startsWith("\u0001ACTION");
if (isAction) {
const substringEnd = messageText.endsWith("\u0001")
? messageText.length - 1
: messageText.length;
messageText = messageText.substring(7, substringEnd);
}
replyData = {
id: messageTags["reply-parent-msg-id"],
user: messageTags["reply-parent-display-name"],
message: messageText,
isAction: isAction,
};
}
const isAction = chatMessageText.startsWith("\u0001ACTION");
if (isAction) {
const substringEnd = chatMessageText.endsWith("\u0001")
? chatMessageText.length - 1
: chatMessageText.length;
chatMessageText = chatMessageText.substring(7, substringEnd);
}
if (props.chatMessage.message.tags.emotes) {
const emoteDataStrings = props.chatMessage.message.tags.emotes.split("/");
let emotePositions: { emote: string; start: number; end: number }[] = [];
for (const emoteDataString of emoteDataStrings) {
const emoteData = emoteDataString.split(":", 2);
const emoteID = emoteData[0];
const emotePositionList = emoteData[1].split(",").map((val) => {
const positions = val.split("-");
return { emote: emoteID, start: +positions[0], end: +positions[1] };
});
emotePositions = emotePositions.concat(emotePositionList);
}
emotePositions.sort((a, b) => a.start - b.start);
let messageTextStart = 0;
while (emotePositions.length > 0) {
const emoteData = emotePositions.shift()!;
const text = chatMessageText.substring(0, emoteData.start - messageTextStart);
if (text !== "") {
messageParts.push(<>{text}</>);
}
const emoteImageURL = `/segments/emotes/${emoteData.emote}/dark-1.0`;
const emoteText = chatMessageText.substring(
emoteData.start - messageTextStart,
emoteData.end + 1 - messageTextStart,
);
chatMessageText = chatMessageText.substring(emoteData.end + 1);
messageTextStart = emoteData.end + 1;
messageParts.push(
<img
src={emoteImageURL}
alt={emoteText}
title={emoteText}
class={styles.chatReplayMessageEmote}
/>,
);
}
if (chatMessageText !== "") {
messageParts.push(<>{chatMessageText}</>);
}
} else {
messageParts.push(<>{chatMessageText}</>);
}
return (
<div
classList={(() => {
const classList: { [className: string]: boolean } = {};
classList[styles.chatReplayMessageText] = true;
classList[styles.chatReplayMessageTextAction] = isAction;
return classList;
})()}
>
<Show when={replyData}>
<div
classList={(() => {
const classList: any = {};
classList[styles.chatReplayMessageReply] = true;
classList[styles.chatReplayMessageTextAction] = replyData!.isAction;
return classList;
})()}
>
<a href={`#chat-replay-message-${replyData!.id}`}>
Replying to {replyData!.user}: {replyData!.message}
</a>
</div>
</Show>
<For each={messageParts}>{(item, index) => item}</For>
</div>
);
};

@ -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;
@ -103,24 +103,20 @@ export function timeAgoFromDateTime(dateTime: DateTime): string {
} }
export function dateTimeFromVideoPlayerTime( export function dateTimeFromVideoPlayerTime(
videoProvider: HLSProvider, fragments: Fragment[],
videoTime: number, videoTime: number,
): DateTime | null { ): 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;
}

@ -9,3 +9,16 @@
.streamTimeSettingLabel { .streamTimeSettingLabel {
margin-right: 3px; margin-right: 3px;
} }
.keyboardShortcutHelp {
position: absolute;
top: 0;
right: 0;
background: #222;
z-index: 1;
padding: 2px;
&[open] {
border: 1px solid #fff;
}
}

@ -9,12 +9,32 @@ media-player {
align-items: normal; align-items: normal;
} }
media-gesture {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
// Used to make the controls appear below the video player // Used to make the controls appear below the video player
media-player:not([data-fullscreen]) media-controls { media-player:not([data-fullscreen]) media-controls {
position: relative; position: relative;
height: auto; height: auto;
} }
media-controls.vds-controls {
&:not([data-fullscreen]) {
visibility: visible;
opacity: 1;
}
&[data-fullscreen] {
justify-content: flex-end;
}
}
media-controls-group { media-controls-group {
display: flex; display: flex;
align-items: center; align-items: center;

@ -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;
@ -251,18 +246,11 @@ export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
preload="auto" preload="auto"
storage="thrimbletrimmer" storage="thrimbletrimmer"
> >
<media-provider <media-provider />
onClick={(event) => { <media-gesture event="pointerup" action="toggle:paused" />
const player = props.mediaPlayer(); <media-gesture event="dblpointerup" action="toggle:fullscreen" />
if (player.paused) {
player.play(event);
} else {
player.pause(event);
}
}}
/>
<media-captions class="vds-captions" /> <media-captions class="vds-captions" />
<media-controls class="vds-controls" hideDelay={0}> <media-controls class="vds-controls">
<media-controls-group class="vds-controls-group"> <media-controls-group class="vds-controls-group">
<media-tooltip> <media-tooltip>
<media-tooltip-trigger> <media-tooltip-trigger>
@ -362,7 +350,7 @@ export const KeyboardShortcuts: Component<KeyboardShortcutProps> = (
props: KeyboardShortcutProps, props: KeyboardShortcutProps,
) => { ) => {
return ( return (
<details> <details class={styles.keyboardShortcutHelp}>
<summary>Keyboard Shortcuts</summary> <summary>Keyboard Shortcuts</summary>
<ul> <ul>
<li>Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)</li> <li>Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)</li>

@ -14,12 +14,11 @@
float: right; float: right;
} }
.keyboardShortcutHelp {
position: absolute;
top: 0;
right: 0;
}
.videoLinks a { .videoLinks a {
margin: 2px; margin: 2px;
} }
.chatContainer {
height: 40vh;
overflow-y: auto;
}

@ -1,16 +1,17 @@
import { import {
Accessor, Accessor,
Component, Component,
createEffect,
createResource, createResource,
createSignal, createSignal,
For, For,
onCleanup,
onMount,
Setter, Setter,
Show, Show,
Suspense, Suspense,
} from "solid-js"; } from "solid-js";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { HLSProvider } from "vidstack"; import { Fragment } from "hls.js";
import { MediaPlayerElement } from "vidstack/elements"; import { MediaPlayerElement } from "vidstack/elements";
import styles from "./Restreamer.module.scss"; import styles from "./Restreamer.module.scss";
import { import {
@ -18,12 +19,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 { ChatDisplay } from "../common/chat";
StreamVideoInfo,
VideoPlayer,
} from "../common/video";
export interface DefaultsData { export interface DefaultsData {
video_channel: string; video_channel: string;
@ -67,13 +65,11 @@ export const Restreamer: Component = () => {
)} )}
</For> </For>
</ul> </ul>
<div class={styles.keyboardShortcutHelp}>
<KeyboardShortcuts includeEditorShortcuts={false} /> <KeyboardShortcuts includeEditorShortcuts={false} />
</div>
<Suspense> <Suspense>
<Show when={defaultsData()}> <Show when={defaultsData()}>
<RestreamerWithDefaults <RestreamerWithDefaults
defaults={defaultsData()} defaults={defaultsData()!}
errorList={pageErrors} errorList={pageErrors}
setErrorList={setPageErrors} setErrorList={setPageErrors}
/> />
@ -90,9 +86,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 +98,30 @@ 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[]>([]);
const [chatContainerElement, setChatContainerElement] = createSignal<HTMLDivElement>();
const [chatScrolledToBottom, setChatScrolledToBottom] = createSignal(true);
onMount(() => {
const player = mediaPlayer();
if (player) {
player.addEventListener("hls-level-loaded", (event) => {
setVideoFragments(event.detail.details.fragments);
});
}
});
const chatScrollTimer = setInterval(() => {
const chatContainer = chatContainerElement();
if (!chatContainer) {
return;
}
const autoscroll = chatScrolledToBottom();
if (autoscroll) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}, 100);
onCleanup(() => clearInterval(chatScrollTimer));
const videoURL = () => { const videoURL = () => {
const streamInfo = streamVideoInfo(); const streamInfo = streamVideoInfo();
@ -130,13 +152,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 "";
} }
@ -157,13 +178,30 @@ 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}
ref={setChatContainerElement}
onScroll={(event) => {
const chatContainer = event.currentTarget;
// Allow a 20 pixel buffer at the bottom of the element
setChatScrolledToBottom(
chatContainer.scrollTop + chatContainer.offsetHeight + 20 >= chatContainer.scrollHeight,
);
}}
>
<ChatDisplay
streamInfo={streamVideoInfo()}
fragments={videoFragments}
videoTime={playerTime}
/>
</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