Compare commits

...

8 Commits

Author SHA1 Message Date
ElementalAlchemist 92a8d1353b Fix keyboard shortcuts help visibility 10 months ago
ElementalAlchemist 5d37b26def Show fullscreen controls at the bottom of the video 10 months ago
ElementalAlchemist cbc55c7345 Keep media controls visible 10 months ago
ElementalAlchemist 9c97b564c7 Replace manual events with vidstack media gestures 10 months ago
ElementalAlchemist 106cd21215 Chat autoscroll 10 months ago
ElementalAlchemist 213b4066ff Factor chat display into its own component 10 months ago
ElementalAlchemist bd866ba1bf Chat replay 10 months ago
ElementalAlchemist 14690ee406 Load chat data in restreamer 10 months 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 = {};
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;
@ -103,24 +103,20 @@ export function timeAgoFromDateTime(dateTime: DateTime): string {
}
export function dateTimeFromVideoPlayerTime(
videoProvider: HLSProvider,
fragments: Fragment[],
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 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;
}

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

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

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

@ -1,16 +1,17 @@
import {
Accessor,
Component,
createEffect,
createResource,
createSignal,
For,
onCleanup,
onMount,
Setter,
Show,
Suspense,
} from "solid-js";
import { DateTime } from "luxon";
import { HLSProvider } from "vidstack";
import { Fragment } from "hls.js";
import { MediaPlayerElement } from "vidstack/elements";
import styles from "./Restreamer.module.scss";
import {
@ -18,12 +19,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 { ChatDisplay } from "../common/chat";
export interface DefaultsData {
video_channel: string;
@ -67,13 +65,11 @@ export const Restreamer: Component = () => {
)}
</For>
</ul>
<div class={styles.keyboardShortcutHelp}>
<KeyboardShortcuts includeEditorShortcuts={false} />
</div>
<KeyboardShortcuts includeEditorShortcuts={false} />
<Suspense>
<Show when={defaultsData()}>
<RestreamerWithDefaults
defaults={defaultsData()}
defaults={defaultsData()!}
errorList={pageErrors}
setErrorList={setPageErrors}
/>
@ -90,9 +86,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 +98,30 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
});
const [playerTime, setPlayerTime] = createSignal<number>(0);
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 streamInfo = streamVideoInfo();
@ -130,13 +152,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 "";
}
@ -157,13 +178,30 @@ 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}
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",
"types": ["vidstack/solid", "vite/client"],
"noEmit": true,
"isolatedModules": true
"isolatedModules": true,
"strictNullChecks": true
}
}

Loading…
Cancel
Save