Skip to content

Commit

Permalink
feat: add initial live timing support to GUI
Browse files Browse the repository at this point in the history
Signed-off-by: eXhumer <[email protected]>
  • Loading branch information
eXhumer committed Dec 3, 2024
1 parent 3fb6ef0 commit 5a3639c
Show file tree
Hide file tree
Showing 7 changed files with 300 additions and 60 deletions.
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ const createMainWindow = (): void => {
height: 600,
width: 800,
webPreferences: {
webSecurity: true,
webSecurity: false,
nodeIntegration: false,
contextIsolation: true,
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
Expand Down
67 changes: 67 additions & 0 deletions src/main_window/React/Component/ContentPlayForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useRef } from 'react';

import { useAppSelector } from '../Hook';
import { F1TVPlatform } from '../Type';

import styles from './ContentPlayForm.module.scss';

const ContentPlayForm = () => {
const inputRef = useRef<HTMLInputElement>(null);
const platformRef = useRef<HTMLSelectElement>(null);

const config = useAppSelector(state => state.f1tv.config);
const location = useAppSelector(state => state.f1tv.location);
const isReady = config !== null && location !== null;

return (
<div className={`${styles['container']} ${styles['padding']}`}>
<h2>F1TV Status: {isReady ? 'Ready' : 'Initializing'}!</h2>
<form className={styles['container']}>
<input
disabled={!isReady}
ref={inputRef}
type='text'
placeholder='Enter Content ID to view'
onChange={e => {
// remove non digit characters
e.target.value = e.target.value.replace(/\D/g, '');
}}
/>
<select
ref={platformRef}
defaultValue={F1TVPlatform.WEB_DASH}
disabled={!isReady}
>
{Object
.values(F1TVPlatform)
.map(val => (
<option
key={val}
value={val}
>{val}</option>
))}
</select>
<button
disabled={!isReady}
type='submit'
onClick={e => {
e.preventDefault();

if (inputRef.current) {
const contentId = parseInt(inputRef.current.value);

if (!isNaN(contentId)) {
inputRef.current.value = '';
}

mainWindow.newPlayer(contentId, platformRef.current.value);
}
}}
> Play Content </button>
</form>
<button onClick={() => f1tv.logout()}>Logout</button>
</div>
);
};

export default ContentPlayForm;
9 changes: 9 additions & 0 deletions src/main_window/React/Component/LiveTiming.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.container {
display: flex;
flex-direction: column;
gap: 1rem;
}

.padding {
padding: 1rem;
}
168 changes: 168 additions & 0 deletions src/main_window/React/Component/LiveTiming.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { HttpTransportType, HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel } from "@microsoft/signalr";
import { useEffect, useState } from "react";

import { deepMerge } from '../../../utils';

import styles from './LiveTiming.module.scss';

const LiveTiming = () => {
const [connection, setConnection] = useState<HubConnection | null>(null);
const [current, setCurrent] = useState<Record<string, unknown> | null>(null);
const [topics, setTopics] = useState<string[]>([]);
const [newTopic, setNewTopic] = useState<string>('');

useEffect(() => {
const newConnection = new HubConnectionBuilder()
.withUrl('https://livetiming.formula1.com/signalrcore', {
transport: HttpTransportType.WebSockets,
})
.withAutomaticReconnect()
.configureLogging(LogLevel.Debug)
.build();

newConnection.onclose(async (err) => {
if (err) {
console.error('Connection closed with error:', err);
return;
}

console.log('Connection closed', newConnection.connectionId, newConnection.state);
});

newConnection.onreconnected(connId => {
console.log('Reconnected to the server!', connId);
});

newConnection.onreconnecting(err => {
if (err) {
console.error('Reconnecting to the server failed:', err);
return;
}

console.log('Reconnecting to the server', newConnection.connectionId, newConnection.state);
});

newConnection
.start()
.then(() => {
console.log('Connection ID', newConnection.connectionId, newConnection.state);
setConnection(newConnection);
setCurrent(null);
})
.catch(err => {
console.error('Failed to start connection:', err);
setConnection(null);
setCurrent(null);
});

return () => {
console.log('Stopping connection', newConnection.connectionId);

newConnection
.stop()
.catch(err => {
console.error('Failed to stop connection:', err);
})
.finally(() => {
setConnection(null);
setCurrent(null);
});
};
}, []);

useEffect(() => {
if (connection && connection.state === HubConnectionState.Connected) {
const onFeed = (topic: string, update: unknown, timestamp: string) => {
console.log('feed', topic, update, timestamp);
setCurrent(prev => deepMerge(prev, { [topic]: update }));
};

connection.on('feed', onFeed);

return () => {
connection.off('feed', onFeed);
};
}
}, [connection]);

useEffect(() => {
if (!connection)
return;

const currentTopics = Object.keys(current || {});
const toSubscribe = topics.filter(topic => !currentTopics.includes(topic));
const toUnsubscribe = currentTopics.filter(topic => !topics.includes(topic));

if (toUnsubscribe.length > 0) {
connection
.invoke('Unsubscribe', toUnsubscribe)
.then(() => {
console.log('Unsubscribed', toUnsubscribe);

setCurrent(prev => {
if (!prev)
return null;

const next = { ...prev };
toUnsubscribe.forEach(topic => delete next[topic]);
return next;
});
});
}

if (toSubscribe.length > 0) {
connection
.invoke('Subscribe', toSubscribe)
.then(subscribed => {
console.log('Subscribed', subscribed);

setCurrent(prev => ({ ...prev, ...subscribed }));
});
}
}, [connection, topics]);

const handleAddTopic = () => {
if (newTopic && !topics.includes(newTopic)) {
setTopics(prev => [...prev, newTopic]);
setNewTopic('');
}
};

const handleRemoveTopic = (topicToRemove: string) => {
setTopics(prev => prev.filter(topic => topic !== topicToRemove));
};

return (
<div className={`${styles['container']} ${styles['padding']}`}>
<h2>Live Timing</h2>
<p>Connection Status: {!connection ? 'Not Connected' : connection.state}</p>

<div>
<input
type="text"
value={newTopic}
onChange={(e) => setNewTopic(e.target.value)}
placeholder="Enter topic name"
/>
<button onClick={handleAddTopic}>Add Topic</button>
</div>

<div>
<h3>Active Topics:</h3>
{topics.map(topic => (
<div key={topic}>
{topic}
<button onClick={() => handleRemoveTopic(topic)}>Remove</button>
</div>
))}
</div>

<div>
<h3>Current Data:</h3>
<pre>{JSON.stringify(current, undefined, 2)}</pre>
</div>
</div>
);
};

export default LiveTiming;
65 changes: 6 additions & 59 deletions src/main_window/React/Component/LoggedInView.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,12 @@
import { useRef } from 'react';
import { useAppSelector } from '../Hook';
import { F1TVPlatform } from '../Type';

import styles from './LoggedInView.module.scss';
import LiveTiming from './LiveTiming';
import ContentPlayForm from './ContentPlayForm';

const LoggedInView = () => {
const inputRef = useRef<HTMLInputElement>(null);
const platformRef = useRef<HTMLSelectElement>(null);

const config = useAppSelector(state => state.f1tv.config);
const location = useAppSelector(state => state.f1tv.location);
const isReady = config !== null && location !== null;

return (
<div className={`${styles['container']} ${styles['padding']}`}>
<h2>F1TV Status: {isReady ? 'Ready' : 'Initializing'}!</h2>
<form className={styles['container']}>
<input
disabled={!isReady}
ref={inputRef}
type='text'
placeholder='Enter Content ID to view'
onChange={e => {
// remove non digit characters
e.target.value = e.target.value.replace(/\D/g, '');
}}
/>
<select
ref={platformRef}
defaultValue={F1TVPlatform.WEB_DASH}
disabled={!isReady}
>
{Object
.values(F1TVPlatform)
.map(val => (
<option
key={val}
value={val}
>{val}</option>
))}
</select>
<button
disabled={!isReady}
type='submit'
onClick={e => {
e.preventDefault();

if (inputRef.current) {
const contentId = parseInt(inputRef.current.value);

if (!isNaN(contentId)) {
inputRef.current.value = '';
}

mainWindow.newPlayer(contentId, platformRef.current.value);
}
}}
> Play Content </button>
</form>
<button onClick={() => f1tv.logout()}>Logout</button>
</div>
<>
<ContentPlayForm />
<LiveTiming />
</>
);
};

Expand Down
49 changes: 49 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// @ts-expect-error ignore types for initial and update for now
export const deepMerge = (initial, update) => {
for (const key in update) {
if (key in initial) {
if (typeof initial[key] in ["function", "symbol"])
throw new Error("invalid value type!");

else if (typeof initial[key] === "object") {
if (Array.isArray(initial[key])) {
if (Array.isArray(update[key]))
initial[key] = update[key];

else {
if (typeof update[key] !== "object")
throw new Error("data type change between initial and update!");

for (const arrKey in update[key]) {
if (parseInt(arrKey) < initial[key].length) {
if (parseInt(arrKey) < 0)
throw new Error("invalid array key!");

// @ts-expect-error ignore 'Element implicitly has an 'any' type because index expression is not of type 'number' for now
initial[key][arrKey] = deepMerge(initial[key][arrKey], update[key][arrKey]);
}

else {
// @ts-expect-error ignore 'Element implicitly has an 'any' type because index expression is not of type 'number' for now
initial[key][arrKey] = update[key][arrKey];
}
}
initial[key] = deepMerge(initial[key], update[key]);
}
}

else {
initial[key] = deepMerge(initial[key], update[key]);
}
}

else
initial[key] = update[key];
}

else
initial[key] = update[key];
}

return initial;
};

0 comments on commit 5a3639c

Please sign in to comment.