Compare commits
23 Commits
8df629a1be
...
4298937535
Author | SHA1 | Date |
---|---|---|
ElementalAlchemist | 4298937535 | 2 weeks ago |
ElementalAlchemist | 5143d15f68 | 2 weeks ago |
ElementalAlchemist | 0fd6a09b1c | 2 weeks ago |
ElementalAlchemist | 2095880d10 | 2 weeks ago |
ElementalAlchemist | bb85eb494d | 2 weeks ago |
ElementalAlchemist | aa649ac4ac | 2 weeks ago |
ElementalAlchemist | aeebf0b7ad | 2 weeks ago |
ElementalAlchemist | 9a8be2f875 | 2 weeks ago |
ElementalAlchemist | 0aed412ccb | 2 weeks ago |
ElementalAlchemist | 89ff17564e | 2 weeks ago |
ElementalAlchemist | 6608555a8b | 2 weeks ago |
ElementalAlchemist | 87e8333d05 | 2 weeks ago |
ElementalAlchemist | 4517ec1e68 | 2 weeks ago |
ElementalAlchemist | 5ab145b031 | 2 weeks ago |
ElementalAlchemist | 2bb8ff6245 | 2 weeks ago |
ElementalAlchemist | 7de2f807b1 | 2 weeks ago |
ElementalAlchemist | c3448542c4 | 2 weeks ago |
ElementalAlchemist | 6b381428ed | 2 weeks ago |
ElementalAlchemist | ae808eedde | 2 weeks ago |
ElementalAlchemist | 369b70bb19 | 2 weeks ago |
ElementalAlchemist | 1c842d9d16 | 2 weeks ago |
ElementalAlchemist | ea438c73db | 2 weeks ago |
ElementalAlchemist | 05a53924f6 | 2 weeks ago |
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
package-lock.json
|
@ -1,4 +1 @@
|
||||
scripts/hls.min.js
|
||||
scripts/luxon.min.js
|
||||
scripts/jcrop.js
|
||||
styles/jcrop.css
|
||||
src/external
|
@ -1,56 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Stream Time</title>
|
||||
<style type="text/css">
|
||||
#clock {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="clock"></div>
|
||||
<div><input type="number" id="delay" value="10" min="0" /> seconds of delay</div>
|
||||
<script type="text/javascript">
|
||||
let busStartTime = null;
|
||||
|
||||
function updateClock() {
|
||||
let delay = parseInt(document.getElementById("delay").value);
|
||||
if (isNaN(delay)) {
|
||||
delay = 0;
|
||||
}
|
||||
let time = (new Date() - busStartTime) / 1000 - delay;
|
||||
|
||||
let sign = "";
|
||||
if (time < 0) {
|
||||
time = -time;
|
||||
sign = "-";
|
||||
}
|
||||
|
||||
let hours = Math.trunc(time / 3600).toString();
|
||||
let mins = Math.trunc((time % 3600) / 60).toString();
|
||||
let secs = Math.trunc(time % 60).toString();
|
||||
|
||||
if (mins.length < 2) {
|
||||
mins = "0" + mins;
|
||||
}
|
||||
if (secs.length < 2) {
|
||||
secs = "0" + secs;
|
||||
}
|
||||
let formatted = sign + hours + ":" + mins + ":" + secs;
|
||||
document.getElementById("clock").innerText = formatted;
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const dataResponse = await fetch("/thrimshim/defaults");
|
||||
const data = await dataResponse.json();
|
||||
busStartTime = new Date(data.bustime_start);
|
||||
|
||||
setInterval(updateClock, 1000);
|
||||
}
|
||||
|
||||
initialize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Before Width: | Height: | Size: 439 B |
Before Width: | Height: | Size: 520 B |
Before Width: | Height: | Size: 196 B |
Before Width: | Height: | Size: 196 B |
Before Width: | Height: | Size: 196 B |
Before Width: | Height: | Size: 196 B |
@ -1,54 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<style>
|
||||
#road-container {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
// margin: 980px 0 0 -100px; // uncomment to move to bottom of screen
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
#road-container div {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
background-position: 0px 0px;
|
||||
}
|
||||
|
||||
#timeofday-left {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
#timeofday-right {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
#stops {
|
||||
z-index: 6;
|
||||
background-image: url(stops.png);
|
||||
}
|
||||
|
||||
#bus {
|
||||
background-repeat: no-repeat;
|
||||
margin-left: 27px;
|
||||
z-index: 8;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="road-container">
|
||||
<div id="bus"></div>
|
||||
<div id="timeofday-left"></div>
|
||||
<div id="timeofday-right"></div>
|
||||
<div id="stops"></div>
|
||||
</div>
|
||||
|
||||
<script src="drive.js"></script>
|
||||
|
||||
</body>
|
||||
|
@ -1,122 +0,0 @@
|
||||
|
||||
const PAGE_WIDTH = 1920;
|
||||
const MINUTES_PER_PAGE = 60;
|
||||
const POINT_WIDTH = PAGE_WIDTH * 8 * 60 / MINUTES_PER_PAGE;
|
||||
const MILES_PER_PAGE = 45;
|
||||
const BUS_POSITION_X = 93;
|
||||
const BASE_ODO = 109.3;
|
||||
const UPDATE_INTERVAL_MS = 5000
|
||||
const WUBLOADER_URL = "";
|
||||
const SKY_URLS = {
|
||||
day: "db_day.png",
|
||||
dawn: "db_dawn.png",
|
||||
dusk: "db_dusk.png",
|
||||
night: "db_night.png",
|
||||
};
|
||||
const BUS_URLS = {
|
||||
day: "bus_day.png",
|
||||
dawn: "bus_day.png",
|
||||
dusk: "bus_day.png",
|
||||
night: "bus_night.png",
|
||||
};
|
||||
|
||||
function setSkyElements(left, right, timeToTransition) {
|
||||
const leftElement = document.getElementById("timeofday-left");
|
||||
const rightElement = document.getElementById("timeofday-right");
|
||||
const busElement = document.getElementById("bus");
|
||||
|
||||
leftElement.style.backgroundImage = `url(${SKY_URLS[left]})`;
|
||||
rightElement.style.backgroundImage = `url(${SKY_URLS[right]})`;
|
||||
|
||||
if (left === right) {
|
||||
leftElement.style.width = "100%";
|
||||
} else {
|
||||
const transitionPercent = timeToTransition / MINUTES_PER_PAGE;
|
||||
leftElement.style.width = `${transitionPercent * 100}%`
|
||||
}
|
||||
|
||||
bus.style.backgroundImage = `url(${BUS_URLS[left]})`;
|
||||
}
|
||||
|
||||
function nextSkyTransition(timeofday, clock) {
|
||||
switch (timeofday) {
|
||||
case "dawn":
|
||||
case "day":
|
||||
return [19 * 60, "dusk"]; // 7pm
|
||||
case "dusk":
|
||||
return [20 * 60, "night"]; // 8pm
|
||||
case "night":
|
||||
return [6 * 60 + 40, "dawn"]; // 6:40am
|
||||
}
|
||||
}
|
||||
|
||||
function setSky(timeofday, clock) {
|
||||
const [transition, newSky] = nextSkyTransition(timeofday, clock);
|
||||
// 1440 minutes in 24h, this code will return time remaining even if
|
||||
// the transition is in the morning and we're currently in the evening.
|
||||
const timeToTransition = (1440 + transition - clock) % 1440;
|
||||
if (timeToTransition < MINUTES_PER_PAGE) {
|
||||
// Transition on screen
|
||||
setSkyElements(timeofday, newSky, timeToTransition);
|
||||
} else {
|
||||
// No transition on screen
|
||||
setSkyElements(timeofday, timeofday, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function setOdo(odo) {
|
||||
const distancePixels = PAGE_WIDTH * (odo - BASE_ODO) / MILES_PER_PAGE;
|
||||
const offset = (BUS_POSITION_X - distancePixels) % POINT_WIDTH;
|
||||
|
||||
const stopsElement = document.getElementById("stops");
|
||||
stopsElement.style.backgroundPosition = `${offset}px 0px`;
|
||||
}
|
||||
|
||||
async function update() {
|
||||
const busDataResponse = await fetch(`${WUBLOADER_URL}/thrimshim/bus/buscam`);
|
||||
if (!busDataResponse.ok) {
|
||||
return;
|
||||
}
|
||||
const busData = await busDataResponse.json();
|
||||
console.log("Got data:", busData);
|
||||
setOdo(busData.odometer);
|
||||
setSky(busData.timeofday, busData.clock_minutes);
|
||||
}
|
||||
|
||||
// Initial conditions, before the first refresh finishes
|
||||
setSky("day", 7 * 60);
|
||||
setOdo(BASE_ODO);
|
||||
|
||||
// Testing mode. Set true to enable.
|
||||
const test = false;
|
||||
if (test) {
|
||||
let h = 0;
|
||||
// Set to how long 1h of in-game time should take in real time
|
||||
const hourTimeMs = 1 * 1000;
|
||||
// Set to how often to update the screen
|
||||
const interval = 30;
|
||||
setInterval(() => {
|
||||
h += interval / hourTimeMs;
|
||||
setOdo(BASE_ODO + 45 * h);
|
||||
if (h < 19) {
|
||||
setSky("day", 60 * h);
|
||||
} else {
|
||||
m = (h % 24) * 60;
|
||||
let tod;
|
||||
if (m < 6 * 60 + 40) {
|
||||
tod = "night";
|
||||
} else if (m < 19 * 60) {
|
||||
tod = "dawn";
|
||||
} else if (m < 20 * 60) {
|
||||
tod = "dusk";
|
||||
} else {
|
||||
tod = "night";
|
||||
}
|
||||
setSky(tod, m);
|
||||
}
|
||||
}, interval);
|
||||
} else {
|
||||
// Do first update immediately, then every UPDATE_INTERVAL_MS
|
||||
setInterval(update, UPDATE_INTERVAL_MS);
|
||||
update();
|
||||
}
|
Before Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 637 B |
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 633 B |
Before Width: | Height: | Size: 600 B |
Before Width: | Height: | Size: 667 B |
Before Width: | Height: | Size: 756 B |
Before Width: | Height: | Size: 747 B |
@ -1,170 +1,12 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>VST Restreamer</title>
|
||||
|
||||
<link rel="stylesheet" href="styles/thrimbletrimmer.css" />
|
||||
|
||||
<script src="scripts/hls.min.js"></script>
|
||||
<script src="scripts/luxon.min.js"></script>
|
||||
<script src="scripts/common-worker.js"></script>
|
||||
<script src="scripts/common.js"></script>
|
||||
<script src="scripts/stream.js"></script>
|
||||
<script src="scripts/keyboard-shortcuts.js"></script>
|
||||
<title>Thrimbletrimmer - Restreamer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="errors"></div>
|
||||
<div id="page-container">
|
||||
<details id="editor-help">
|
||||
<summary>Keyboard Shortcuts</summary>
|
||||
<ul>
|
||||
<li>Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)</li>
|
||||
<li>K or Space: Toggle pause</li>
|
||||
<li>M: Toggle mute</li>
|
||||
<li>J: Back 10 seconds</li>
|
||||
<li>L: Forward 10 seconds</li>
|
||||
<li>Left arrow: Back 5 seconds</li>
|
||||
<li>Right arrow: Forward 5 seconds</li>
|
||||
<li>Shift+J: Back 1 second</li>
|
||||
<li>Shift+L: Forward 1 second</li>
|
||||
<li>Comma (,): Back 1 frame</li>
|
||||
<li>Period (.): Forward 1 frame</li>
|
||||
<li>Equals (=): Increase playback speed one step</li>
|
||||
<li>Hyphen (-): Decrease playback speed one step</li>
|
||||
<li>Shift+=: 2x or maximum playback speed</li>
|
||||
<li>Shift+-: Minimum playback speed</li>
|
||||
<li>Backspace: Reset playback speed to 1x</li>
|
||||
</ul>
|
||||
</details>
|
||||
<form id="stream-time-settings">
|
||||
<div>
|
||||
<label for="stream-time-setting-stream" class="field-label">Stream</label>
|
||||
<input type="text" id="stream-time-setting-stream" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="stream-time-setting-start" class="field-label">Start Time</label>
|
||||
<input type="text" id="stream-time-setting-start" value="0:10:00" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="stream-time-setting-end" class="field-label">End Time</label>
|
||||
<input type="text" id="stream-time-setting-end" />
|
||||
</div>
|
||||
<div>
|
||||
<div id="stream-time-frame-of-reference">
|
||||
<input
|
||||
type="radio"
|
||||
name="time-frame-of-reference"
|
||||
id="stream-time-frame-of-reference-utc"
|
||||
value="1"
|
||||
/>
|
||||
<label for="stream-time-frame-of-reference-utc">UTC</label>
|
||||
<input
|
||||
type="radio"
|
||||
name="time-frame-of-reference"
|
||||
id="stream-time-frame-of-reference-bus"
|
||||
value="2"
|
||||
/>
|
||||
<label for="stream-time-frame-of-reference-bus">Bus Time</label>
|
||||
<input
|
||||
type="radio"
|
||||
name="time-frame-of-reference"
|
||||
id="stream-time-frame-of-reference-ago"
|
||||
value="3"
|
||||
checked
|
||||
/>
|
||||
<label for="stream-time-frame-of-reference-ago">Time Ago</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button id="stream-time-settings-submit" type="submit">Update Time Range</button>
|
||||
</div>
|
||||
<div>
|
||||
<a href="" id="stream-time-link">Link to this time range</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<video id="video" preload="auto"></video>
|
||||
|
||||
<div id="video-controls">
|
||||
<div id="video-controls-bar">
|
||||
<div>
|
||||
<img
|
||||
id="video-controls-play-pause"
|
||||
src="images/video-controls/play.png"
|
||||
class="click"
|
||||
/>
|
||||
</div>
|
||||
<div id="video-controls-time">
|
||||
<span id="video-controls-current-time"></span>
|
||||
/
|
||||
<span id="video-controls-duration"></span>
|
||||
</div>
|
||||
<div id="video-controls-spacer"></div>
|
||||
<div id="video-controls-volume">
|
||||
<img
|
||||
id="video-controls-volume-mute"
|
||||
src="images/video-controls/volume.png"
|
||||
class="click"
|
||||
/>
|
||||
<progress id="video-controls-volume-level" value="0.5" class="click"></progress>
|
||||
</div>
|
||||
<div>
|
||||
<select id="video-controls-playback-speed"></select>
|
||||
</div>
|
||||
<div>
|
||||
<select id="video-controls-quality"></select>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
id="video-controls-fullscreen"
|
||||
src="images/video-controls/fullscreen.png"
|
||||
class="click"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<progress id="video-controls-playback-position" value="0" class="click"></progress>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="#" id="download">Download Video</a>
|
||||
<a href="#" id="download-frame">Download Current Frame as Image</a>
|
||||
<a href="#" id="time-converter-link">Convert Times</a>
|
||||
</div>
|
||||
<form id="time-converter" class="hidden">
|
||||
<h2>Time Converter</h2>
|
||||
<div id="time-converter-time-container">
|
||||
<input class="time-converter-time" type="text" placeholder="Time to convert" />
|
||||
</div>
|
||||
<img
|
||||
src="images/plus.png"
|
||||
id="time-converter-add-time"
|
||||
tooltip="Add time conversion field"
|
||||
class="click"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div>
|
||||
From:
|
||||
<input name="time-converter-from" id="time-converter-from-utc" type="radio" value="1" />
|
||||
<label for="time-converter-from-utc">UTC</label>
|
||||
<input name="time-converter-from" id="time-converter-from-bus" type="radio" value="2" />
|
||||
<label for="time-converter-from-bus">Bus Time</label>
|
||||
<input name="time-converter-from" id="time-converter-from-ago" type="radio" value="3" />
|
||||
<label for="time-converter-from-ago">Time Ago</label>
|
||||
</div>
|
||||
<div>
|
||||
To:
|
||||
<input name="time-converter-to" id="time-converter-to-utc" type="radio" value="1" />
|
||||
<label for="time-converter-to-utc">UTC</label>
|
||||
<input name="time-converter-to" id="time-converter-to-bus" type="radio" value="2" />
|
||||
<label for="time-converter-to-bus">Bus Time</label>
|
||||
<input name="time-converter-to" id="time-converter-to-ago" type="radio" value="3" />
|
||||
<label for="time-converter-to-ago">Time Ago</label>
|
||||
</div>
|
||||
<button type="submit">Convert Times</button>
|
||||
</form>
|
||||
<div id="root"></div>
|
||||
|
||||
<div id="chat-replay"></div>
|
||||
</div>
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "thrimbletrimmer",
|
||||
"version": "4.0.0",
|
||||
"description": "Video editor frontend for Wubloader",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"sass": "1.80.6",
|
||||
"solid-devtools": "0.30.1",
|
||||
"typescript": "5.6.3",
|
||||
"url": "0.11.4",
|
||||
"vite": "5.4.10",
|
||||
"vite-plugin-solid": "2.10.2",
|
||||
"@types/luxon": "3.4.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "1.5.17",
|
||||
"luxon": "3.4.4",
|
||||
"media-icons": "1.1.5",
|
||||
"solid-js": "1.9.3",
|
||||
"vidstack": "1.12.12"
|
||||
}
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
self.importScripts("luxon.min.js", "common-worker.js");
|
||||
|
||||
var DateTime = luxon.DateTime;
|
||||
luxon.Settings.defaultZone = "utc";
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const chatLoadData = event.data;
|
||||
|
||||
const segmentMetadata = chatLoadData.segmentMetadata;
|
||||
for (const segmentData of segmentMetadata) {
|
||||
segmentData.rawStart = DateTime.fromMillis(segmentData.rawStart);
|
||||
segmentData.rawEnd = DateTime.fromMillis(segmentData.rawEnd);
|
||||
}
|
||||
|
||||
const fetchURL = `/${chatLoadData.stream}/chat.json?start=${chatLoadData.start}&end=${chatLoadData.end}`;
|
||||
const chatResponse = await fetch(fetchURL);
|
||||
if (!chatResponse.ok) {
|
||||
return;
|
||||
}
|
||||
const chatRawData = await chatResponse.json();
|
||||
|
||||
const chatData = [];
|
||||
for (const chatLine of chatRawData) {
|
||||
if (
|
||||
chatLine.command !== "PRIVMSG" &&
|
||||
chatLine.command !== "CLEARMSG" &&
|
||||
chatLine.command !== "CLEARCHAT" &&
|
||||
chatLine.command !== "USERNOTICE"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const when = DateTime.fromSeconds(chatLine.time);
|
||||
const displayWhen = videoHumanTimeFromDateTimeWithFragments(segmentMetadata, when);
|
||||
// Here, we just push each line successively into the list. This assumes data is provided to us in chronological order.
|
||||
chatData.push({ message: chatLine, when: when.toMillis(), displayWhen: displayWhen });
|
||||
}
|
||||
self.postMessage(chatData);
|
||||
};
|
||||
|
||||
function videoHumanTimeFromDateTimeWithFragments(fragmentMetadata, dateTime) {
|
||||
for (const segmentData of fragmentMetadata) {
|
||||
if (dateTime >= segmentData.rawStart && dateTime <= segmentData.rawEnd) {
|
||||
const playerTime =
|
||||
segmentData.playerStart + dateTime.diff(segmentData.rawStart).as("seconds");
|
||||
return videoHumanTimeFromVideoPlayerTime(playerTime);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) {
|
||||
const hours = Math.floor(videoPlayerTime / 3600);
|
||||
let minutes = Math.floor((videoPlayerTime % 3600) / 60);
|
||||
let seconds = Math.floor(videoPlayerTime % 60);
|
||||
let milliseconds = Math.floor((videoPlayerTime * 1000) % 1000);
|
||||
|
||||
while (minutes.toString().length < 2) {
|
||||
minutes = `0${minutes}`;
|
||||
}
|
||||
while (seconds.toString().length < 2) {
|
||||
seconds = `0${seconds}`;
|
||||
}
|
||||
while (milliseconds.toString().length < 3) {
|
||||
milliseconds = `0${milliseconds}`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
return `${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
@ -1,736 +0,0 @@
|
||||
var DateTime = luxon.DateTime;
|
||||
var Interval = luxon.Interval;
|
||||
luxon.Settings.defaultZone = "utc";
|
||||
|
||||
var globalBusStartTime = DateTime.fromISO("1970-01-01T00:00:00");
|
||||
var globalStreamName = "";
|
||||
var globalStartTimeString = "";
|
||||
var globalEndTimeString = "";
|
||||
|
||||
var globalPlayer = null;
|
||||
var globalSetUpControls = false;
|
||||
var globalSeekTimer = null;
|
||||
|
||||
var globalChatData = [];
|
||||
var globalLoadChatWorker = null;
|
||||
|
||||
Hls.DefaultConfig.maxBufferHole = 600;
|
||||
|
||||
const VIDEO_FRAMES_PER_SECOND = 30;
|
||||
|
||||
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4, 8];
|
||||
|
||||
function commonPageSetup() {
|
||||
if (!Hls.isSupported()) {
|
||||
addError(
|
||||
"Your browser doesn't support MediaSource extensions. Video playback and editing won't work.",
|
||||
);
|
||||
}
|
||||
|
||||
globalLoadChatWorker = new Worker("scripts/chat-load.js");
|
||||
}
|
||||
|
||||
function addError(errorText) {
|
||||
const errorElement = document.createElement("div");
|
||||
errorElement.innerText = errorText;
|
||||
|
||||
const dismissElement = document.createElement("a");
|
||||
dismissElement.classList.add("error-dismiss");
|
||||
dismissElement.innerText = "[X]";
|
||||
errorElement.appendChild(dismissElement);
|
||||
dismissElement.addEventListener("click", (event) => {
|
||||
const errorHost = document.getElementById("errors");
|
||||
errorHost.removeChild(errorElement);
|
||||
});
|
||||
|
||||
const errorHost = document.getElementById("errors");
|
||||
errorHost.appendChild(errorElement);
|
||||
}
|
||||
|
||||
async function loadVideoPlayer(playlistURL) {
|
||||
let rangedPlaylistURL = assembleVideoPlaylistURL(playlistURL);
|
||||
const videoElement = document.getElementById("video");
|
||||
|
||||
videoElement.addEventListener("loadedmetadata", (_event) => {
|
||||
setUpVideoControls();
|
||||
sendChatLogLoadData();
|
||||
});
|
||||
|
||||
videoElement.addEventListener("loadeddata", (_event) => {
|
||||
const qualitySelector = document.getElementById("video-controls-quality");
|
||||
globalPlayer.currentLevel = +qualitySelector.value;
|
||||
});
|
||||
|
||||
globalPlayer = new Hls();
|
||||
globalPlayer.attachMedia(video);
|
||||
return new Promise((resolve, _reject) => {
|
||||
globalPlayer.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
const startTime = getStartTime();
|
||||
const endTime = getEndTime();
|
||||
if (endTime && endTime.diff(startTime).milliseconds < 0) {
|
||||
addError(
|
||||
"End time is before the start time. This will prevent video loading and cause other problems.",
|
||||
);
|
||||
}
|
||||
globalPlayer.loadSource(rangedPlaylistURL);
|
||||
|
||||
globalPlayer.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
if (data.reason === "no level found in manifest") {
|
||||
addError(
|
||||
"There is no video data between the specified start and end times. Change the times so that there is video content to play.",
|
||||
);
|
||||
} else {
|
||||
console.log("A fatal network error occurred; retrying", data);
|
||||
globalPlayer.startLoad();
|
||||
}
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.log("A fatal media error occurred; retrying", data);
|
||||
globalPlayer.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.log("A fatal error occurred; resetting video player", data);
|
||||
addError(
|
||||
"Some sort of video player error occurred. Thrimbletrimmer is resetting the video player.",
|
||||
);
|
||||
resetVideoPlayer();
|
||||
}
|
||||
} else {
|
||||
console.log("A non-fatal video player error occurred; HLS.js will retry", data);
|
||||
}
|
||||
});
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadVideoPlayerFromDefaultPlaylist() {
|
||||
const playlistURL = `/playlist/${globalStreamName}.m3u8`;
|
||||
await loadVideoPlayer(playlistURL);
|
||||
}
|
||||
|
||||
function resetVideoPlayer() {
|
||||
updateSegmentPlaylist();
|
||||
}
|
||||
|
||||
function updateSegmentPlaylist() {
|
||||
const videoElement = document.getElementById("video");
|
||||
const currentPlaybackRate = videoElement.playbackRate;
|
||||
globalPlayer.destroy();
|
||||
loadVideoPlayerFromDefaultPlaylist();
|
||||
// The playback rate isn't maintained when destroying and reattaching hls.js
|
||||
videoElement.playbackRate = currentPlaybackRate;
|
||||
}
|
||||
|
||||
function setUpVideoControls() {
|
||||
// Setting this up so it's removed from the event doesn't work; loadedmetadata fires twice anyway.
|
||||
// We still need to prevent double-setup, so here we are.
|
||||
if (globalSetUpControls) {
|
||||
return;
|
||||
}
|
||||
globalSetUpControls = true;
|
||||
|
||||
const videoElement = document.getElementById("video");
|
||||
|
||||
const playPauseButton = document.getElementById("video-controls-play-pause");
|
||||
if (videoElement.paused) {
|
||||
playPauseButton.src = "images/video-controls/play.png";
|
||||
} else {
|
||||
playPauseButton.src = "images/video-controls/pause.png";
|
||||
}
|
||||
|
||||
const togglePlayState = (_event) => {
|
||||
if (videoElement.paused) {
|
||||
videoElement.play();
|
||||
} else {
|
||||
videoElement.pause();
|
||||
}
|
||||
};
|
||||
playPauseButton.addEventListener("click", togglePlayState);
|
||||
videoElement.addEventListener("click", (event) => {
|
||||
if (!videoElement.controls) {
|
||||
togglePlayState(event);
|
||||
}
|
||||
});
|
||||
|
||||
videoElement.addEventListener("play", (_event) => {
|
||||
playPauseButton.src = "images/video-controls/pause.png";
|
||||
});
|
||||
videoElement.addEventListener("pause", (_event) => {
|
||||
playPauseButton.src = "images/video-controls/play.png";
|
||||
});
|
||||
|
||||
const currentTime = document.getElementById("video-controls-current-time");
|
||||
currentTime.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
|
||||
videoElement.addEventListener("timeupdate", (_event) => {
|
||||
currentTime.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
|
||||
});
|
||||
|
||||
const duration = document.getElementById("video-controls-duration");
|
||||
duration.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.duration);
|
||||
videoElement.addEventListener("durationchange", (_event) => {
|
||||
duration.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.duration);
|
||||
});
|
||||
|
||||
const volumeMuted = document.getElementById("video-controls-volume-mute");
|
||||
if (videoElement.muted) {
|
||||
volumeMuted.src = "images/video-controls/volume-mute.png";
|
||||
} else {
|
||||
volumeMuted.src = "images/video-controls/volume.png";
|
||||
}
|
||||
const volumeLevel = document.getElementById("video-controls-volume-level");
|
||||
const defaultVolume = +(localStorage.getItem("volume") ?? 0.5);
|
||||
if (isNaN(defaultVolume)) {
|
||||
defaultVolume = 0.5;
|
||||
} else if (defaultVolume < 0) {
|
||||
defaultVolume = 0;
|
||||
} else if (defaultVolume > 1) {
|
||||
defaultVolume = 1;
|
||||
}
|
||||
videoElement.volume = defaultVolume;
|
||||
volumeLevel.value = videoElement.volume;
|
||||
|
||||
volumeMuted.addEventListener("click", (_event) => {
|
||||
videoElement.muted = !videoElement.muted;
|
||||
});
|
||||
volumeLevel.addEventListener("click", (event) => {
|
||||
videoElement.volume = event.offsetX / event.target.offsetWidth;
|
||||
videoElement.muted = false;
|
||||
});
|
||||
videoElement.addEventListener("volumechange", (_event) => {
|
||||
if (videoElement.muted) {
|
||||
volumeMuted.src = "images/video-controls/volume-mute.png";
|
||||
} else {
|
||||
volumeMuted.src = "images/video-controls/volume.png";
|
||||
}
|
||||
volumeLevel.value = videoElement.volume;
|
||||
localStorage.setItem("volume", videoElement.volume);
|
||||
});
|
||||
|
||||
const playbackSpeed = document.getElementById("video-controls-playback-speed");
|
||||
for (const speed of PLAYBACK_RATES) {
|
||||
const speedOption = document.createElement("option");
|
||||
speedOption.value = speed;
|
||||
speedOption.innerText = `${speed}x`;
|
||||
if (speed === 1) {
|
||||
speedOption.selected = true;
|
||||
}
|
||||
playbackSpeed.appendChild(speedOption);
|
||||
}
|
||||
playbackSpeed.addEventListener("change", (_event) => {
|
||||
const speed = +playbackSpeed.value;
|
||||
videoElement.playbackRate = speed;
|
||||
});
|
||||
|
||||
const quality = document.getElementById("video-controls-quality");
|
||||
const defaultQuality = localStorage.getItem("quality");
|
||||
for (const [qualityIndex, qualityLevel] of globalPlayer.levels.entries()) {
|
||||
const qualityOption = document.createElement("option");
|
||||
qualityOption.value = qualityIndex;
|
||||
qualityOption.innerText = qualityLevel.name;
|
||||
if (qualityLevel.name === defaultQuality) {
|
||||
qualityOption.selected = true;
|
||||
}
|
||||
quality.appendChild(qualityOption);
|
||||
}
|
||||
localStorage.setItem("quality", quality.options[quality.options.selectedIndex].innerText);
|
||||
quality.addEventListener("change", (_event) => {
|
||||
globalPlayer.currentLevel = +quality.value;
|
||||
localStorage.setItem("quality", quality.options[quality.options.selectedIndex].innerText);
|
||||
});
|
||||
|
||||
const fullscreen = document.getElementById("video-controls-fullscreen");
|
||||
fullscreen.addEventListener("click", (_event) => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoElement.requestFullscreen();
|
||||
}
|
||||
});
|
||||
videoElement.addEventListener("fullscreenchange", (_event) => {
|
||||
if (document.fullscreenElement) {
|
||||
videoElement.controls = true;
|
||||
} else {
|
||||
videoElement.controls = false;
|
||||
}
|
||||
});
|
||||
|
||||
const playbackPosition = document.getElementById("video-controls-playback-position");
|
||||
playbackPosition.max = videoElement.duration;
|
||||
playbackPosition.value = videoElement.currentTime;
|
||||
videoElement.addEventListener("durationchange", (_event) => {
|
||||
playbackPosition.max = videoElement.duration;
|
||||
});
|
||||
videoElement.addEventListener("timeupdate", (_event) => {
|
||||
playbackPosition.value = videoElement.currentTime;
|
||||
});
|
||||
playbackPosition.addEventListener("click", (event) => {
|
||||
const newPosition = (event.offsetX / event.target.offsetWidth) * videoElement.duration;
|
||||
videoElement.currentTime = newPosition;
|
||||
playbackPosition.value = newPosition;
|
||||
});
|
||||
|
||||
/* Sometimes a mysterious issue occurs loading segments of the video when seeking.
|
||||
* When this happens, twiddling the qualities tends to fix it. Here, we attempt to
|
||||
* detect this situation and fix it automatically.
|
||||
*/
|
||||
videoElement.addEventListener("seeking", (_event) => {
|
||||
// If we don't get a "seeked" event soon after the "seeking" event, we assume there's
|
||||
// a loading error.
|
||||
// To handle this, we set up a timed handler to pick this up.
|
||||
if (globalSeekTimer !== null) {
|
||||
clearTimeout(globalSeekTimer);
|
||||
globalSeekTimer = null;
|
||||
}
|
||||
globalSeekTimer = setTimeout(() => {
|
||||
const currentLevel = globalPlayer.currentLevel;
|
||||
globalPlayer.currentLevel = -1;
|
||||
globalPlayer.currentLevel = currentLevel;
|
||||
}, 500);
|
||||
});
|
||||
videoElement.addEventListener("seeked", (_event) => {
|
||||
// Since we got the seek, cancel the timed twiddling of qualities
|
||||
if (globalSeekTimer !== null) {
|
||||
clearTimeout(globalSeekTimer);
|
||||
globalSeekTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function dateTimeMathObjectFromBusTime(busTime) {
|
||||
// We need to handle inputs like "-0:10:15" in a way that consistently makes the time negative.
|
||||
// Since we can't assign the negative sign to any particular part, we'll check for the whole thing here.
|
||||
let direction = 1;
|
||||
if (busTime.startsWith("-")) {
|
||||
busTime = busTime.slice(1);
|
||||
direction = -1;
|
||||
}
|
||||
|
||||
const parts = busTime.split(":", 3);
|
||||
const hours = parseInt(parts[0]) * direction;
|
||||
const minutes = (parts[1] || 0) * direction;
|
||||
const seconds = (parts[2] || 0) * direction;
|
||||
return { hours: hours, minutes: minutes, seconds: seconds };
|
||||
}
|
||||
|
||||
function dateTimeFromBusTime(busTime) {
|
||||
return globalBusStartTime.plus(dateTimeMathObjectFromBusTime(busTime));
|
||||
}
|
||||
|
||||
function busTimeFromDateTime(dateTime) {
|
||||
const diff = dateTime.diff(globalBusStartTime);
|
||||
return formatIntervalForDisplay(diff);
|
||||
}
|
||||
|
||||
function formatIntervalForDisplay(interval) {
|
||||
if (interval.milliseconds < 0) {
|
||||
const negativeInterval = interval.negate();
|
||||
return `-${negativeInterval.toFormat("hh:mm:ss.SSS")}`;
|
||||
}
|
||||
return interval.toFormat("hh:mm:ss.SSS");
|
||||
}
|
||||
|
||||
function dateTimeFromWubloaderTime(wubloaderTime) {
|
||||
return DateTime.fromISO(wubloaderTime);
|
||||
}
|
||||
|
||||
function wubloaderTimeFromDateTime(dateTime) {
|
||||
if (!dateTime) {
|
||||
return null;
|
||||
}
|
||||
// Not using ISO here because Luxon doesn't give us a quick way to print an ISO8601 string with no offset.
|
||||
return dateTime.toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
|
||||
}
|
||||
|
||||
function busTimeFromWubloaderTime(wubloaderTime) {
|
||||
if (wubloaderTime === "") {
|
||||
return "";
|
||||
}
|
||||
const dt = dateTimeFromWubloaderTime(wubloaderTime);
|
||||
return busTimeFromDateTime(dt);
|
||||
}
|
||||
|
||||
function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) {
|
||||
const hours = Math.floor(videoPlayerTime / 3600);
|
||||
let minutes = Math.floor((videoPlayerTime % 3600) / 60);
|
||||
let seconds = Math.floor(videoPlayerTime % 60);
|
||||
let milliseconds = Math.floor((videoPlayerTime * 1000) % 1000);
|
||||
|
||||
while (minutes.toString().length < 2) {
|
||||
minutes = `0${minutes}`;
|
||||
}
|
||||
while (seconds.toString().length < 2) {
|
||||
seconds = `0${seconds}`;
|
||||
}
|
||||
while (milliseconds.toString().length < 3) {
|
||||
milliseconds = `0${milliseconds}`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
return `${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
function videoPlayerTimeFromVideoHumanTime(videoHumanTime) {
|
||||
let timeParts = videoHumanTime.split(":", 3);
|
||||
let hours;
|
||||
let minutes;
|
||||
let seconds;
|
||||
|
||||
if (timeParts.length < 2) {
|
||||
hours = 0;
|
||||
minutes = 0;
|
||||
seconds = +timeParts[0];
|
||||
} else if (timeParts.length < 3) {
|
||||
hours = 0;
|
||||
minutes = parseInt(timeParts[0]);
|
||||
seconds = +timeParts[1];
|
||||
} else {
|
||||
hours = parseInt(timeParts[0]);
|
||||
minutes = parseInt(timeParts[1]);
|
||||
seconds = +timeParts[2];
|
||||
}
|
||||
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
function dateTimeFromVideoPlayerTime(videoPlayerTime) {
|
||||
const segmentList = getSegmentList();
|
||||
let segmentStartTime;
|
||||
let segmentStartISOTime;
|
||||
for (const segment of segmentList) {
|
||||
const segmentEndTime = segment.start + segment.duration;
|
||||
if (videoPlayerTime >= segment.start && videoPlayerTime < segmentEndTime) {
|
||||
segmentStartTime = segment.start;
|
||||
segmentStartISOTime = segment.rawProgramDateTime;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (segmentStartISOTime === undefined) {
|
||||
return null;
|
||||
}
|
||||
const wubloaderDateTime = DateTime.fromISO(segmentStartISOTime);
|
||||
const offset = videoPlayerTime - segmentStartTime;
|
||||
return wubloaderDateTime.plus({ seconds: offset });
|
||||
}
|
||||
|
||||
function videoPlayerTimeFromDateTime(dateTime) {
|
||||
const segmentTimes = getSegmentTimes();
|
||||
for (const segmentData of segmentTimes) {
|
||||
const segmentStart = segmentData.rawStart;
|
||||
const segmentEnd = segmentData.rawEnd;
|
||||
if (dateTime >= segmentStart && dateTime <= segmentEnd) {
|
||||
return segmentData.playerStart + dateTime.diff(segmentStart).as("seconds");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function videoHumanTimeFromDateTime(dateTime) {
|
||||
const videoPlayerTime = videoPlayerTimeFromDateTime(dateTime);
|
||||
if (videoPlayerTime === null) {
|
||||
return null;
|
||||
}
|
||||
return videoHumanTimeFromVideoPlayerTime(videoPlayerTime);
|
||||
}
|
||||
|
||||
function assembleVideoPlaylistURL(basePlaylistURL) {
|
||||
let playlistURL = basePlaylistURL;
|
||||
|
||||
const query = startAndEndTimeQuery();
|
||||
if (query.toString() !== "") {
|
||||
playlistURL += "?" + query.toString();
|
||||
}
|
||||
return playlistURL;
|
||||
}
|
||||
|
||||
function startAndEndTimeQuery() {
|
||||
const startTime = getStartTime();
|
||||
const endTime = getEndTime();
|
||||
|
||||
const query = new URLSearchParams();
|
||||
if (startTime) {
|
||||
query.append("start", wubloaderTimeFromDateTime(startTime));
|
||||
}
|
||||
if (endTime) {
|
||||
query.append("end", wubloaderTimeFromDateTime(endTime));
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
function getSegmentList() {
|
||||
return globalPlayer.latencyController.levelDetails.fragments;
|
||||
}
|
||||
|
||||
function hasSegmentList() {
|
||||
if (
|
||||
globalPlayer &&
|
||||
globalPlayer.latencyController &&
|
||||
globalPlayer.latencyController.levelDetails &&
|
||||
globalPlayer.latencyController.levelDetails.fragments
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSegmentTimes() {
|
||||
const segmentList = getSegmentList();
|
||||
const segmentTimes = [];
|
||||
for (const segment of segmentList) {
|
||||
const segmentStart = DateTime.fromISO(segment.rawProgramDateTime);
|
||||
const segmentEnd = segmentStart.plus({ seconds: segment.duration });
|
||||
segmentTimes.push({ rawStart: segmentStart, rawEnd: segmentEnd, playerStart: segment.start });
|
||||
}
|
||||
return segmentTimes;
|
||||
}
|
||||
|
||||
function downloadFrame() {
|
||||
const videoElement = document.getElementById("video");
|
||||
const dateTime = dateTimeFromVideoPlayerTime(videoElement.currentTime);
|
||||
const url = `/frame/${globalStreamName}/source.png?timestamp=${wubloaderTimeFromDateTime(
|
||||
dateTime,
|
||||
)}`;
|
||||
// Avoid : as it causes problems on Windows
|
||||
const filename = `${dateTime.toFormat("yyyy-LL-dd'T'HH-mm-ss.SSS")}.png`;
|
||||
triggerDownload(url, filename);
|
||||
}
|
||||
|
||||
function triggerDownload(url, filename) {
|
||||
// URL must be same-origin.
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("download", filename);
|
||||
link.href = url;
|
||||
link.setAttribute("target", "_blank");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function sendChatLogLoadData() {
|
||||
let startTime = getStartTime();
|
||||
let endTime = getEndTime();
|
||||
if (!startTime || !endTime) {
|
||||
return;
|
||||
}
|
||||
startTime = wubloaderTimeFromDateTime(startTime);
|
||||
endTime = wubloaderTimeFromDateTime(endTime);
|
||||
const segmentMetadata = getSegmentTimes();
|
||||
for (const segmentData of segmentMetadata) {
|
||||
segmentData.rawStart = segmentData.rawStart.toMillis();
|
||||
segmentData.rawEnd = segmentData.rawEnd.toMillis();
|
||||
}
|
||||
|
||||
const message = {
|
||||
stream: globalStreamName,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
segmentMetadata: segmentMetadata,
|
||||
};
|
||||
globalLoadChatWorker.postMessage(message);
|
||||
}
|
||||
|
||||
function updateChatDataFromWorkerResponse(chatData) {
|
||||
for (const chatLine of chatData) {
|
||||
chatLine.when = DateTime.fromMillis(chatLine.when);
|
||||
}
|
||||
globalChatData = chatData;
|
||||
}
|
||||
|
||||
function renderChatMessage(chatMessageData) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
if (chatMessage.command !== "PRIVMSG" || chatMessage.params[0] !== `#${globalStreamName}`) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sendTimeElement = document.createElement("div");
|
||||
sendTimeElement.classList.add("chat-replay-message-time");
|
||||
sendTimeElement.innerText = chatMessageData.displayWhen;
|
||||
|
||||
const senderNameElement = createMessageSenderElement(chatMessageData);
|
||||
|
||||
const messageTextElement = document.createElement("div");
|
||||
messageTextElement.classList.add("chat-replay-message-text");
|
||||
|
||||
if (chatMessage.tags.hasOwnProperty("reply-parent-msg-id")) {
|
||||
const replyParentID = chatMessage.tags["reply-parent-msg-id"];
|
||||
const replyParentSender = chatMessage.tags["reply-parent-display-name"];
|
||||
let replyParentMessageText = chatMessage.tags["reply-parent-msg-body"];
|
||||
const replyContainer = document.createElement("div");
|
||||
const replyTextContainer = document.createElement("a");
|
||||
|
||||
if (replyParentMessageText.startsWith("\u0001ACTION")) {
|
||||
replyContainer.classList.add("chat-replay-message-text-action");
|
||||
const substringEnd = replyParentMessageText.endsWith("\u0001")
|
||||
? replyParentMessageText.length - 1
|
||||
: replyParentMessageText;
|
||||
replyParentMessageText = replyParentMessageText.substring(7, substringEnd);
|
||||
}
|
||||
|
||||
replyTextContainer.href = `#chat-replay-message-${replyParentID}`;
|
||||
replyTextContainer.innerText = `Replying to ${replyParentSender}: ${replyParentMessageText}`;
|
||||
replyContainer.appendChild(replyTextContainer);
|
||||
replyContainer.classList.add("chat-replay-message-reply");
|
||||
messageTextElement.appendChild(replyContainer);
|
||||
}
|
||||
|
||||
addChatMessageTextToElement(chatMessageData, messageTextElement);
|
||||
|
||||
const messageContainer = createMessageContainer(chatMessageData, false);
|
||||
messageContainer.appendChild(sendTimeElement);
|
||||
messageContainer.appendChild(senderNameElement);
|
||||
messageContainer.appendChild(messageTextElement);
|
||||
return messageContainer;
|
||||
}
|
||||
|
||||
function renderSystemMessages(chatMessageData) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
if (chatMessage.command !== "USERNOTICE" || chatMessage.params[0] != `#${globalStreamName}`) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
|
||||
const sendTimeElement = document.createElement("div");
|
||||
sendTimeElement.classList.add("chat-replay-message-time");
|
||||
sendTimeElement.innerText = chatMessageData.displayWhen;
|
||||
|
||||
const systemTextElement = document.createElement("div");
|
||||
systemTextElement.classList.add("chat-replay-message-text");
|
||||
systemTextElement.classList.add("chat-replay-message-system");
|
||||
let systemMsg = chatMessage.tags["system-msg"];
|
||||
if (!systemMsg && chatMessage.tags["msg-id"] === "announcement") {
|
||||
systemMsg = "Announcement";
|
||||
}
|
||||
systemTextElement.appendChild(document.createTextNode(systemMsg));
|
||||
|
||||
const firstMessageContainer = createMessageContainer(chatMessageData, true);
|
||||
firstMessageContainer.appendChild(sendTimeElement);
|
||||
firstMessageContainer.appendChild(systemTextElement);
|
||||
messages.push(firstMessageContainer);
|
||||
|
||||
if (chatMessage.params.length === 1) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const emptySendTimeElement = document.createElement("div");
|
||||
emptySendTimeElement.classList.add("chat-replay-message-time");
|
||||
|
||||
const senderNameElement = createMessageSenderElement(chatMessageData);
|
||||
|
||||
const messageTextElement = document.createElement("div");
|
||||
messageTextElement.classList.add("chat-replay-message-text");
|
||||
addChatMessageTextToElement(chatMessageData, messageTextElement);
|
||||
|
||||
const secondMessageContainer = createMessageContainer(chatMessageData, false);
|
||||
secondMessageContainer.appendChild(emptySendTimeElement);
|
||||
secondMessageContainer.appendChild(senderNameElement);
|
||||
secondMessageContainer.appendChild(messageTextElement);
|
||||
messages.push(secondMessageContainer);
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function createMessageContainer(chatMessageData, isSystemMessage) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
const messageContainer = document.createElement("div");
|
||||
messageContainer.classList.add("chat-replay-message");
|
||||
if (chatMessage.tags.hasOwnProperty("id")) {
|
||||
if (isSystemMessage) {
|
||||
messageContainer.id = `chat-replay-message-system-${chatMessage.tags.id}`;
|
||||
} else {
|
||||
messageContainer.id = `chat-replay-message-${chatMessage.tags.id}`;
|
||||
}
|
||||
}
|
||||
messageContainer.dataset.sender = chatMessage.sender;
|
||||
return messageContainer;
|
||||
}
|
||||
|
||||
function getMessageDisplayName(chatMessageData) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
if (chatMessage.tags.hasOwnProperty("display-name")) {
|
||||
return chatMessage.tags["display-name"];
|
||||
}
|
||||
return chatMessage.sender;
|
||||
}
|
||||
|
||||
function createMessageSenderElement(chatMessageData) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
const senderNameElement = document.createElement("div");
|
||||
senderNameElement.classList.add("chat-replay-message-sender");
|
||||
if (chatMessage.tags.hasOwnProperty("color")) {
|
||||
senderNameElement.style.color = chatMessage.tags.color;
|
||||
}
|
||||
senderNameElement.innerText = getMessageDisplayName(chatMessageData);
|
||||
return senderNameElement;
|
||||
}
|
||||
|
||||
function addChatMessageTextToElement(chatMessageData, messageTextElement) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
|
||||
let chatMessageText = chatMessage.params[1];
|
||||
if (chatMessageText.startsWith("\u0001ACTION")) {
|
||||
messageTextElement.classList.add("chat-replay-message-text-action");
|
||||
const substringEnd = chatMessageText.endsWith("\u0001")
|
||||
? chatMessageText.length - 1
|
||||
: chatMessageText.length;
|
||||
chatMessageText = chatMessageText.substring(7, substringEnd);
|
||||
}
|
||||
|
||||
if (chatMessage.tags.emotes) {
|
||||
const emoteDataStrings = chatMessage.tags.emotes.split("/");
|
||||
let emotePositions = [];
|
||||
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 messageText = [chatMessageText];
|
||||
while (emotePositions.length > 0) {
|
||||
const emoteData = emotePositions.pop(); // Pop the highest-index element from the array
|
||||
let text = messageText.shift();
|
||||
const textAndEmote = [text.substring(0, emoteData.start)];
|
||||
|
||||
const emoteImg = document.createElement("img");
|
||||
emoteImg.src = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteData.emote}/default/dark/1.0`;
|
||||
const emoteText = text.substring(emoteData.start, emoteData.end + 1);
|
||||
emoteImg.alt = emoteText;
|
||||
emoteImg.title = emoteText;
|
||||
const emoteContainer = document.createElement("span");
|
||||
emoteContainer.classList.add("chat-replay-message-emote");
|
||||
emoteContainer.appendChild(emoteImg);
|
||||
textAndEmote.push(emoteContainer);
|
||||
|
||||
const remainingText = text.substring(emoteData.end + 1);
|
||||
if (remainingText !== "") {
|
||||
textAndEmote.push(remainingText);
|
||||
}
|
||||
messageText = textAndEmote.concat(messageText);
|
||||
}
|
||||
|
||||
for (const messagePart of messageText) {
|
||||
if (typeof messagePart === "string") {
|
||||
const node = document.createTextNode(messagePart);
|
||||
messageTextElement.appendChild(node);
|
||||
} else {
|
||||
messageTextElement.appendChild(messagePart);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messageTextElement.appendChild(document.createTextNode(chatMessageText));
|
||||
}
|
||||
}
|
@ -1,163 +0,0 @@
|
||||
function moveSpeed(amount) {
|
||||
const videoElement = document.getElementById("video");
|
||||
let currentIndex = PLAYBACK_RATES.indexOf(videoElement.playbackRate);
|
||||
if (currentIndex === -1) {
|
||||
addError("The playback rate has somehow gone very wrong.");
|
||||
return;
|
||||
}
|
||||
currentIndex += amount;
|
||||
if (currentIndex < 0 || currentIndex >= PLAYBACK_RATES.length) {
|
||||
return; // We've reached/exceeded the edge
|
||||
}
|
||||
setSpeed(videoElement, PLAYBACK_RATES[currentIndex]);
|
||||
}
|
||||
|
||||
function increaseSpeed() {
|
||||
moveSpeed(1);
|
||||
}
|
||||
|
||||
function decreaseSpeed() {
|
||||
moveSpeed(-1);
|
||||
}
|
||||
|
||||
function setSpeed(videoElement, speed) {
|
||||
videoElement.playbackRate = speed;
|
||||
const playbackSelector = document.getElementById("video-controls-playback-speed");
|
||||
playbackSelector.value = speed;
|
||||
}
|
||||
|
||||
document.addEventListener("keypress", (event) => {
|
||||
if (event.target.nodeName === "INPUT" || event.target.nodeName === "TEXTAREA") {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoElement = document.getElementById("video");
|
||||
switch (event.key) {
|
||||
case "0":
|
||||
videoElement.currentTime = 0;
|
||||
break;
|
||||
case "1":
|
||||
videoElement.currentTime = videoElement.duration * 0.1;
|
||||
break;
|
||||
case "2":
|
||||
videoElement.currentTime = videoElement.duration * 0.2;
|
||||
break;
|
||||
case "3":
|
||||
videoElement.currentTime = videoElement.duration * 0.3;
|
||||
break;
|
||||
case "4":
|
||||
videoElement.currentTime = videoElement.duration * 0.4;
|
||||
break;
|
||||
case "5":
|
||||
videoElement.currentTime = videoElement.duration * 0.5;
|
||||
break;
|
||||
case "6":
|
||||
videoElement.currentTime = videoElement.duration * 0.6;
|
||||
break;
|
||||
case "7":
|
||||
videoElement.currentTime = videoElement.duration * 0.7;
|
||||
break;
|
||||
case "8":
|
||||
videoElement.currentTime = videoElement.duration * 0.8;
|
||||
break;
|
||||
case "9":
|
||||
videoElement.currentTime = videoElement.duration * 0.9;
|
||||
break;
|
||||
case "j":
|
||||
videoElement.currentTime -= 10;
|
||||
break;
|
||||
case "k":
|
||||
case "K":
|
||||
case " ":
|
||||
if (videoElement.paused) {
|
||||
videoElement.play();
|
||||
} else {
|
||||
videoElement.pause();
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "l":
|
||||
videoElement.currentTime += 10;
|
||||
break;
|
||||
case "J":
|
||||
videoElement.currentTime -= 1;
|
||||
break;
|
||||
case "L":
|
||||
videoElement.currentTime += 1;
|
||||
break;
|
||||
case "m":
|
||||
videoElement.muted = !videoElement.muted;
|
||||
break;
|
||||
case ",":
|
||||
case "<":
|
||||
videoElement.currentTime -= 1 / VIDEO_FRAMES_PER_SECOND;
|
||||
break;
|
||||
case ".":
|
||||
case ">":
|
||||
videoElement.currentTime += 1 / VIDEO_FRAMES_PER_SECOND;
|
||||
break;
|
||||
case "=":
|
||||
increaseSpeed();
|
||||
break;
|
||||
case "+":
|
||||
const playbackRate = videoElement.playbackRate;
|
||||
if (playbackRate < 2) {
|
||||
setSpeed(videoElement, 2);
|
||||
} else {
|
||||
setSpeed(videoElement, PLAYBACK_RATES[PLAYBACK_RATES.length - 1]);
|
||||
}
|
||||
break;
|
||||
case "-":
|
||||
decreaseSpeed();
|
||||
break;
|
||||
case "_":
|
||||
setSpeed(videoElement, PLAYBACK_RATES[0]);
|
||||
break;
|
||||
case "[":
|
||||
if (typeof setCurrentRangeStartToVideoTime === "function") {
|
||||
setCurrentRangeStartToVideoTime();
|
||||
}
|
||||
break;
|
||||
case "]":
|
||||
if (typeof setCurrentRangeEndToVideoTime === "function") {
|
||||
setCurrentRangeEndToVideoTime();
|
||||
}
|
||||
break;
|
||||
case "o":
|
||||
if (typeof moveToPreviousRange === "function") {
|
||||
moveToPreviousRange();
|
||||
}
|
||||
break;
|
||||
case "p":
|
||||
if (typeof moveToNextRange === "function") {
|
||||
moveToNextRange();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// For whatever reason, arrow keys don't work for keypress. We can use keydown for them.
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.target.nodeName === "INPUT" || event.target.nodeName === "TEXTAREA") {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoElement = document.getElementById("video");
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
videoElement.currentTime -= 5;
|
||||
break;
|
||||
case "ArrowRight":
|
||||
videoElement.currentTime += 5;
|
||||
break;
|
||||
case "Backspace":
|
||||
event.preventDefault();
|
||||
videoElement.playbackRate = 1;
|
||||
document.getElementById("video-controls-playback-speed").value = 1;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
@ -1,345 +0,0 @@
|
||||
const TIME_FRAME_UTC = 1;
|
||||
const TIME_FRAME_BUS = 2;
|
||||
const TIME_FRAME_AGO = 3;
|
||||
|
||||
var globalLoadedVideoPlayer = false;
|
||||
var globalVideoTimeReference = TIME_FRAME_AGO;
|
||||
var globalChatPreviousRenderTime = null;
|
||||
|
||||
window.addEventListener("DOMContentLoaded", async (event) => {
|
||||
commonPageSetup();
|
||||
globalLoadChatWorker.onmessage = (event) => {
|
||||
updateChatDataFromWorkerResponse(event.data);
|
||||
initialChatRender();
|
||||
};
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
if (queryParams.has("start")) {
|
||||
document.getElementById("stream-time-frame-of-reference-utc").checked = true;
|
||||
document.getElementById("stream-time-setting-start").value = queryParams.get("start");
|
||||
if (queryParams.has("end")) {
|
||||
document.getElementById("stream-time-setting-end").value = queryParams.get("end");
|
||||
}
|
||||
}
|
||||
if (queryParams.has("stream")) {
|
||||
document.getElementById("stream-time-setting-stream").value = queryParams.get("stream");
|
||||
}
|
||||
|
||||
await loadDefaults();
|
||||
|
||||
const timeSettingsForm = document.getElementById("stream-time-settings");
|
||||
timeSettingsForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
updateTimeSettings();
|
||||
});
|
||||
|
||||
document.getElementById("download-frame").addEventListener("click", (_event) => {
|
||||
downloadFrame();
|
||||
});
|
||||
|
||||
const timeConversionForm = document.getElementById("time-converter");
|
||||
timeConversionForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
convertEnteredTimes();
|
||||
});
|
||||
|
||||
const timeConversionLink = document.getElementById("time-converter-link");
|
||||
timeConversionLink.addEventListener("click", (_event) => {
|
||||
const timeConversionForm = document.getElementById("time-converter");
|
||||
timeConversionForm.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
const addTimeConversionButton = document.getElementById("time-converter-add-time");
|
||||
addTimeConversionButton.addEventListener("click", (_event) => {
|
||||
const newField = document.createElement("input");
|
||||
newField.classList.add("time-converter-time");
|
||||
newField.type = "text";
|
||||
newField.placeholder = "Time to convert";
|
||||
const container = document.getElementById("time-converter-time-container");
|
||||
container.appendChild(newField);
|
||||
});
|
||||
|
||||
await updateTimeSettings();
|
||||
|
||||
const videoPlayer = document.getElementById("video");
|
||||
videoPlayer.addEventListener("loadedmetadata", (_event) => initialChatRender());
|
||||
videoPlayer.addEventListener("timeupdate", (_event) => updateChatRender());
|
||||
});
|
||||
|
||||
async function loadDefaults() {
|
||||
const defaultDataResponse = await fetch("/thrimshim/defaults");
|
||||
if (!defaultDataResponse.ok) {
|
||||
addError(
|
||||
"Failed to load Thrimbletrimmer data. This probably means that everything is broken (or, possibly, just that the Wubloader host is down). Please sound the alarm.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const defaultData = await defaultDataResponse.json();
|
||||
|
||||
const streamNameField = document.getElementById("stream-time-setting-stream");
|
||||
if (streamNameField.value === "") {
|
||||
streamNameField.value = defaultData.video_channel;
|
||||
}
|
||||
|
||||
globalBusStartTime = DateTime.fromISO(defaultData.bustime_start);
|
||||
}
|
||||
|
||||
// Gets the start time of the video from settings. Returns an invalid date object if the user entered bad data.
|
||||
function getStartTime() {
|
||||
return dateTimeFromTimeString(globalStartTimeString, globalVideoTimeReference);
|
||||
}
|
||||
|
||||
// Gets the end time of the video from settings. Returns null if there's no end time. Returns an invalid date object if the user entered bad data.
|
||||
function getEndTime() {
|
||||
if (globalEndTimeString === "") {
|
||||
return null;
|
||||
}
|
||||
return dateTimeFromTimeString(globalEndTimeString, globalVideoTimeReference);
|
||||
}
|
||||
|
||||
function dateTimeFromTimeString(timeString, timeStringFormat) {
|
||||
switch (timeStringFormat) {
|
||||
case 1:
|
||||
return dateTimeFromWubloaderTime(timeString);
|
||||
case 2:
|
||||
return dateTimeFromBusTime(timeString);
|
||||
case 3:
|
||||
return DateTime.now().setZone("utc").minus(dateTimeMathObjectFromBusTime(timeString));
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTimeSettings() {
|
||||
updateStoredTimeSettings();
|
||||
if (globalLoadedVideoPlayer) {
|
||||
updateSegmentPlaylist();
|
||||
} else {
|
||||
loadVideoPlayerFromDefaultPlaylist();
|
||||
globalLoadedVideoPlayer = true;
|
||||
}
|
||||
|
||||
updateDownloadLink();
|
||||
|
||||
const startTime = getStartTime();
|
||||
const endTime = getEndTime();
|
||||
const query = new URLSearchParams({
|
||||
stream: globalStreamName,
|
||||
start: wubloaderTimeFromDateTime(startTime),
|
||||
});
|
||||
if (endTime) {
|
||||
query.append("end", wubloaderTimeFromDateTime(endTime));
|
||||
}
|
||||
document.getElementById("stream-time-link").href = `?${query}`;
|
||||
}
|
||||
|
||||
function generateDownloadURL(startTime, endTime, downloadType, allowHoles, quality) {
|
||||
const startURLTime = wubloaderTimeFromDateTime(startTime);
|
||||
const endURLTime = wubloaderTimeFromDateTime(endTime);
|
||||
|
||||
const query = new URLSearchParams({
|
||||
type: downloadType,
|
||||
allow_holes: allowHoles,
|
||||
});
|
||||
if (startURLTime) {
|
||||
query.append("start", startURLTime);
|
||||
}
|
||||
if (endURLTime) {
|
||||
query.append("end", endURLTime);
|
||||
}
|
||||
|
||||
const downloadURL = `/cut/${globalStreamName}/${quality}.ts?${query}`;
|
||||
return downloadURL;
|
||||
}
|
||||
|
||||
function updateDownloadLink() {
|
||||
const downloadLink = document.getElementById("download");
|
||||
const downloadURL = generateDownloadURL(getStartTime(), getEndTime(), "smart", true, "source");
|
||||
downloadLink.href = downloadURL;
|
||||
}
|
||||
|
||||
function updateStoredTimeSettings() {
|
||||
globalStreamName = document.getElementById("stream-time-setting-stream").value;
|
||||
globalStartTimeString = document.getElementById("stream-time-setting-start").value;
|
||||
globalEndTimeString = document.getElementById("stream-time-setting-end").value;
|
||||
|
||||
const radioSelection = document.querySelectorAll("#stream-time-frame-of-reference > input");
|
||||
for (radioItem of radioSelection) {
|
||||
if (radioItem.checked) {
|
||||
globalVideoTimeReference = +radioItem.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function convertEnteredTimes() {
|
||||
let timeConvertFrom = undefined;
|
||||
const timeConvertFromSelection = document.querySelectorAll(
|
||||
"#time-converter input[name=time-converter-from]",
|
||||
);
|
||||
for (const convertFromItem of timeConvertFromSelection) {
|
||||
if (convertFromItem.checked) {
|
||||
timeConvertFrom = +convertFromItem.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!timeConvertFrom) {
|
||||
addError("Failed to convert times - input format not specified");
|
||||
return;
|
||||
}
|
||||
|
||||
let timeConvertTo = undefined;
|
||||
const timeConvertToSelection = document.querySelectorAll(
|
||||
"#time-converter input[name=time-converter-to]",
|
||||
);
|
||||
for (const convertToItem of timeConvertToSelection) {
|
||||
if (convertToItem.checked) {
|
||||
timeConvertTo = +convertToItem.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!timeConvertTo) {
|
||||
addError("Failed to convert times - output format not specified");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeFieldList = document.getElementsByClassName("time-converter-time");
|
||||
const now = DateTime.now().setZone("utc");
|
||||
for (const timeField of timeFieldList) {
|
||||
const enteredTime = timeField.value;
|
||||
if (enteredTime === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let time = dateTimeFromTimeString(enteredTime, timeConvertFrom);
|
||||
if (!time) {
|
||||
addError(
|
||||
`Failed to parse the time '${enteredTime}' as a value of the selected "convert from" time format.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (timeConvertTo === TIME_FRAME_UTC) {
|
||||
timeField.value = wubloaderTimeFromDateTime(time);
|
||||
} else if (timeConvertTo === TIME_FRAME_BUS) {
|
||||
timeField.value = busTimeFromDateTime(time);
|
||||
} else if (timeConvertTo === TIME_FRAME_AGO) {
|
||||
const difference = now.diff(time);
|
||||
timeField.value = formatIntervalForDisplay(difference);
|
||||
}
|
||||
}
|
||||
|
||||
if (timeConvertTo === TIME_FRAME_UTC) {
|
||||
document.getElementById("time-converter-from-utc").checked = true;
|
||||
} else if (timeConvertTo === TIME_FRAME_BUS) {
|
||||
document.getElementById("time-converter-from-bus").checked = true;
|
||||
} else if (timeConvertTo === TIME_FRAME_AGO) {
|
||||
document.getElementById("time-converter-from-ago").checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function initialChatRender() {
|
||||
if (!globalChatData || globalChatData.length === 0) {
|
||||
return;
|
||||
}
|
||||
const videoPlayer = document.getElementById("video");
|
||||
const videoTime = videoPlayer.currentTime;
|
||||
const videoDateTime = dateTimeFromVideoPlayerTime(videoTime);
|
||||
const chatReplayContainer = document.getElementById("chat-replay");
|
||||
chatReplayContainer.innerHTML = "";
|
||||
|
||||
for (const chatMessage of globalChatData) {
|
||||
if (chatMessage.when > videoDateTime) {
|
||||
break;
|
||||
}
|
||||
handleChatMessage(chatReplayContainer, chatMessage);
|
||||
}
|
||||
|
||||
globalChatPreviousRenderTime = videoTime;
|
||||
chatReplayContainer.scrollTop = chatReplayContainer.scrollHeight;
|
||||
}
|
||||
|
||||
function updateChatRender() {
|
||||
if (!globalChatData || globalChatData.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!hasSegmentList()) {
|
||||
// The update is due to a stream refresh, so we'll wait for the initial render instead
|
||||
return;
|
||||
}
|
||||
const videoPlayer = document.getElementById("video");
|
||||
const videoTime = videoPlayer.currentTime;
|
||||
const chatReplayContainer = document.getElementById("chat-replay");
|
||||
const wasScrolledToBottom =
|
||||
chatReplayContainer.scrollTop + chatReplayContainer.offsetHeight >=
|
||||
chatReplayContainer.scrollHeight;
|
||||
|
||||
if (videoTime < globalChatPreviousRenderTime) {
|
||||
initialChatRender();
|
||||
} else {
|
||||
const videoDateTime = dateTimeFromVideoPlayerTime(videoTime);
|
||||
const lastAddedTime = dateTimeFromVideoPlayerTime(globalChatPreviousRenderTime);
|
||||
|
||||
let rangeMin = 0;
|
||||
let rangeMax = globalChatData.length;
|
||||
let lastChatIndex = Math.floor((rangeMin + rangeMax) / 2);
|
||||
while (rangeMax - rangeMin > 1) {
|
||||
if (globalChatData[lastChatIndex].when === lastAddedTime) {
|
||||
break;
|
||||
}
|
||||
if (globalChatData[lastChatIndex].when < lastAddedTime) {
|
||||
rangeMin = lastChatIndex;
|
||||
} else {
|
||||
rangeMax = lastChatIndex;
|
||||
}
|
||||
lastChatIndex = Math.floor((rangeMin + rangeMax) / 2);
|
||||
}
|
||||
|
||||
if (lastChatIndex === 0 && globalChatData[0].when > lastAddedTime) {
|
||||
lastChatIndex = -1;
|
||||
}
|
||||
|
||||
for (let chatIndex = lastChatIndex + 1; chatIndex < globalChatData.length; chatIndex++) {
|
||||
const chatMessage = globalChatData[chatIndex];
|
||||
if (chatMessage.when > videoDateTime) {
|
||||
break;
|
||||
}
|
||||
handleChatMessage(chatReplayContainer, chatMessage);
|
||||
}
|
||||
}
|
||||
globalChatPreviousRenderTime = videoTime;
|
||||
if (wasScrolledToBottom) {
|
||||
chatReplayContainer.scrollTop = chatReplayContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatMessage(chatReplayContainer, chatMessage) {
|
||||
if (chatMessage.message.command === "PRIVMSG") {
|
||||
const chatDOM = renderChatMessage(chatMessage);
|
||||
if (chatDOM) {
|
||||
chatReplayContainer.appendChild(chatDOM);
|
||||
}
|
||||
} else if (chatMessage.message.command === "CLEARMSG") {
|
||||
const removedID = chatMessage.message.tags["target-msg-id"];
|
||||
const targetMessageElem = document.getElementById(`chat-replay-message-${removedID}`);
|
||||
if (targetMessageElem) {
|
||||
targetMessageElem.classList.add("chat-replay-message-cleared");
|
||||
}
|
||||
} else if (chatMessage.message.command === "CLEARCHAT") {
|
||||
if (chatMessage.message.params.length > 1) {
|
||||
const removedSender = chatMessage.message.params[1];
|
||||
for (const messageElem of chatReplayContainer.children) {
|
||||
if (messageElem.dataset.sender === removedSender) {
|
||||
messageElem.classList.add("chat-replay-message-cleared");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const messageElem of chatReplayContainer.children) {
|
||||
messageElem.classList.add("chat-replay-message-cleared");
|
||||
}
|
||||
}
|
||||
} else if (chatMessage.message.command === "USERNOTICE") {
|
||||
const chatDOMList = renderSystemMessages(chatMessage);
|
||||
for (const chatDOM of chatDOMList) {
|
||||
chatReplayContainer.appendChild(chatDOM);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,444 +0,0 @@
|
||||
let viewingTemplate = null;
|
||||
let googleUser = null;
|
||||
let templateData = [];
|
||||
|
||||
function googleOnSignIn(googleUserData) {
|
||||
googleUser = googleUserData;
|
||||
const signInElem = document.getElementById("google-auth-sign-in");
|
||||
const signOutElem = document.getElementById("google-auth-sign-out");
|
||||
signInElem.classList.add("hidden");
|
||||
signOutElem.classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function googleSignOut() {
|
||||
if (googleUser) {
|
||||
googleUser = null;
|
||||
await gapi.auth2.getAuthInstance().signOut();
|
||||
const signInElem = document.getElementById("google-auth-sign-in");
|
||||
const signOutElem = document.getElementById("google-auth-sign-out");
|
||||
signInElem.classList.remove("hidden");
|
||||
signOutElem.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", async (event) => {
|
||||
document.getElementById("template-new-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const errorListContainer = document.getElementById("template-new-errors");
|
||||
errorListContainer.innerHTML = "";
|
||||
|
||||
const form = document.getElementById("template-new-form");
|
||||
const formData = new FormData(form);
|
||||
|
||||
const name = formData.get("name");
|
||||
|
||||
const imageFile = formData.get("image");
|
||||
const fileReader = new FileReader();
|
||||
const fileReaderCompletePromise = new Promise((resolve, reject) => {
|
||||
fileReader.addEventListener("loadend", (event) => resolve());
|
||||
});
|
||||
fileReader.readAsDataURL(imageFile);
|
||||
|
||||
const description = formData.get("description");
|
||||
const attribution = formData.get("attribution");
|
||||
|
||||
const cropXStart = parseInt(formData.get("cropxstart"), 10);
|
||||
const cropYStart = parseInt(formData.get("cropystart"), 10);
|
||||
const cropXEnd = parseInt(formData.get("cropxend"), 10);
|
||||
const cropYEnd = parseInt(formData.get("cropyend"), 10);
|
||||
|
||||
const locXStart = parseInt(formData.get("locxstart"), 10);
|
||||
const locYStart = parseInt(formData.get("locystart"), 10);
|
||||
const locXEnd = parseInt(formData.get("locxend"), 10);
|
||||
const locYEnd = parseInt(formData.get("locyend"), 10);
|
||||
|
||||
if (
|
||||
isNaN(cropXStart) ||
|
||||
isNaN(cropYStart) ||
|
||||
isNaN(cropXEnd) ||
|
||||
isNaN(cropYEnd) ||
|
||||
isNaN(locXStart) ||
|
||||
isNaN(locYStart) ||
|
||||
isNaN(locXEnd) ||
|
||||
isNaN(locYEnd)
|
||||
) {
|
||||
const parseNumbersError = document.createElement("li");
|
||||
parseNumbersError.innerText = "All crop and location information must be entered";
|
||||
errorListContainer.appendChild(parseNumbersError);
|
||||
}
|
||||
|
||||
await fileReaderCompletePromise;
|
||||
|
||||
const imageDataURL = fileReader.result;
|
||||
if (!imageDataURL.startsWith("data:image/png;base64,")) {
|
||||
const imageReadError = document.createElement("li");
|
||||
imageReadError.innerText = "Couldn't read the image data, or the image wasn't a valid PNG";
|
||||
errorListContainer.appendChild(imageReadError);
|
||||
return;
|
||||
}
|
||||
const image = imageDataURL.substring(22);
|
||||
|
||||
const submitData = {
|
||||
name: name,
|
||||
image: image,
|
||||
description: description,
|
||||
attribution: attribution,
|
||||
crop: [cropXStart, cropYStart, cropXEnd, cropYEnd],
|
||||
location: [locXStart, locYStart, locXEnd, locYEnd],
|
||||
};
|
||||
if (googleUser) {
|
||||
submitData.token = googleUser.getAuthResponse().id_token;
|
||||
}
|
||||
|
||||
if (!errorListContainer.hasChildNodes()) {
|
||||
const submitResponse = await fetch("/thrimshim/add-template", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(submitData),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!submitResponse.ok) {
|
||||
const submitError = document.createElement("li");
|
||||
submitError.innerText = await submitResponse.text();
|
||||
errorListContainer.appendChild(submitError);
|
||||
return;
|
||||
}
|
||||
|
||||
addTemplate(templateData.length, submitData);
|
||||
templateData.push(submitData);
|
||||
|
||||
form.reset();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("google-auth-sign-out").addEventListener("click", (_event) => {
|
||||
googleSignOut();
|
||||
});
|
||||
|
||||
const templateDataResponse = await fetch("/thrimshim/templates");
|
||||
if (!templateDataResponse.ok) {
|
||||
return;
|
||||
}
|
||||
templateData = await templateDataResponse.json();
|
||||
|
||||
for (const [index, template] of templateData.entries()) {
|
||||
addTemplate(index, template);
|
||||
}
|
||||
});
|
||||
|
||||
function generateTemplateDOM(index, template) {
|
||||
const { name, description, attribution, crop, location } = template;
|
||||
|
||||
const editForm = document.createElement("form");
|
||||
editForm.id = `template-data-edit-form-${index}`;
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
const nameReadCell = document.createElement("div");
|
||||
nameReadCell.classList.add("template-data-view");
|
||||
nameReadCell.innerText = name;
|
||||
const nameEditCell = document.createElement("div");
|
||||
nameEditCell.classList.add("template-data-edit", "hidden");
|
||||
const nameEditField = document.createElement("input");
|
||||
nameEditField.type = "text";
|
||||
nameEditField.name = "name";
|
||||
nameEditField.value = name;
|
||||
nameEditField.form = editForm.id;
|
||||
nameEditCell.appendChild(nameEditField);
|
||||
nameCell.appendChild(nameReadCell);
|
||||
nameCell.appendChild(nameEditCell);
|
||||
|
||||
const descriptionCell = document.createElement("td");
|
||||
const descriptionReadCell = document.createElement("div");
|
||||
descriptionReadCell.classList.add("template-data-view");
|
||||
descriptionReadCell.innerText = description;
|
||||
const descriptionEditCell = document.createElement("div");
|
||||
descriptionEditCell.classList.add("template-data-edit", "hidden");
|
||||
const descriptionEditField = document.createElement("textarea");
|
||||
descriptionEditField.name = "description";
|
||||
descriptionEditField.value = description;
|
||||
descriptionEditField.form = editForm.id;
|
||||
descriptionEditCell.appendChild(descriptionEditField);
|
||||
descriptionCell.appendChild(descriptionReadCell);
|
||||
descriptionCell.appendChild(descriptionEditCell);
|
||||
|
||||
const attributionCell = document.createElement("td");
|
||||
const attributionReadCell = document.createElement("div");
|
||||
attributionReadCell.classList.add("template-data-view");
|
||||
attributionReadCell.innerText = attribution;
|
||||
const attributionEditCell = document.createElement("div");
|
||||
attributionEditCell.classList.add("template-data-edit", "hidden");
|
||||
const attributionEditField = document.createElement("input");
|
||||
attributionEditField.type = "text";
|
||||
attributionEditField.name = "attribution";
|
||||
attributionEditField.value = attribution;
|
||||
attributionEditField.form = editForm.id;
|
||||
attributionEditCell.appendChild(attributionEditField);
|
||||
attributionCell.appendChild(attributionReadCell);
|
||||
attributionCell.appendChild(attributionEditCell);
|
||||
|
||||
const cropCell = document.createElement("td");
|
||||
const cropReadCell = document.createElement("div");
|
||||
cropReadCell.classList.add("template-data-view");
|
||||
cropReadCell.innerText = `(${crop[0]}, ${crop[1]}) to (${crop[2]}, ${crop[3]})`;
|
||||
const cropEditCell = document.createElement("div");
|
||||
cropEditCell.classList.add("template-data-edit", "hidden");
|
||||
|
||||
const cropXStartField = document.createElement("input");
|
||||
cropXStartField.name = "cropxstart";
|
||||
setCoordNumberFieldProps(cropXStartField, "X");
|
||||
cropXStartField.value = crop[0];
|
||||
cropXStartField.form = editForm.id;
|
||||
const cropYStartField = document.createElement("input");
|
||||
cropYStartField.name = "cropystart";
|
||||
setCoordNumberFieldProps(cropYStartField, "Y");
|
||||
cropYStartField.value = crop[1];
|
||||
cropYStartField.form = editForm.id;
|
||||
const cropXEndField = document.createElement("input");
|
||||
cropXEndField.name = "cropxend";
|
||||
setCoordNumberFieldProps(cropXEndField, "X");
|
||||
cropXEndField.value = crop[2];
|
||||
cropXEndField.form = editForm.id;
|
||||
const cropYEndField = document.createElement("input");
|
||||
cropYEndField.name = "cropyend";
|
||||
setCoordNumberFieldProps(cropYEndField, "Y");
|
||||
cropYEndField.value = crop[3];
|
||||
cropYEndField.form = editForm.id;
|
||||
|
||||
cropEditCell.appendChild(document.createTextNode("("));
|
||||
cropEditCell.appendChild(cropXStartField);
|
||||
cropEditCell.appendChild(document.createTextNode(", "));
|
||||
cropEditCell.appendChild(cropYStartField);
|
||||
cropEditCell.appendChild(document.createTextNode(") to ("));
|
||||
cropEditCell.appendChild(cropXEndField);
|
||||
cropEditCell.appendChild(document.createTextNode(", "));
|
||||
cropEditCell.appendChild(cropYEndField);
|
||||
cropEditCell.appendChild(document.createTextNode(")"));
|
||||
|
||||
cropCell.appendChild(cropReadCell);
|
||||
cropCell.appendChild(cropEditCell);
|
||||
|
||||
const locationCell = document.createElement("td");
|
||||
const locationReadCell = document.createElement("div");
|
||||
locationReadCell.classList.add("template-data-view");
|
||||
locationReadCell.innerText = `(${location[0]}, ${location[1]}) to (${location[2]}, ${location[3]})`;
|
||||
const locationEditCell = document.createElement("div");
|
||||
locationEditCell.classList.add("template-data-edit", "hidden");
|
||||
|
||||
const locationXStartField = document.createElement("input");
|
||||
locationXStartField.name = "locxstart";
|
||||
setCoordNumberFieldProps(locationXStartField, "X");
|
||||
locationXStartField.value = location[0];
|
||||
locationXStartField.form = editForm.id;
|
||||
const locationYStartField = document.createElement("input");
|
||||
locationYStartField.name = "locystart";
|
||||
setCoordNumberFieldProps(locationYStartField, "Y");
|
||||
locationYStartField.value = location[1];
|
||||
locationYStartField.form = editForm.id;
|
||||
const locationXEndField = document.createElement("input");
|
||||
locationXEndField.name = "locxend";
|
||||
setCoordNumberFieldProps(locationXEndField, "X");
|
||||
locationXEndField.value = location[2];
|
||||
locationXEndField.form = editForm.id;
|
||||
const locationYEndField = document.createElement("input");
|
||||
locationYEndField.name = "locyend";
|
||||
setCoordNumberFieldProps(locationYEndField, "Y");
|
||||
locationYEndField.value = location[3];
|
||||
locationYEndField.form = editForm.id;
|
||||
|
||||
locationEditCell.appendChild(document.createTextNode("("));
|
||||
locationEditCell.appendChild(locationXStartField);
|
||||
locationEditCell.appendChild(document.createTextNode(", "));
|
||||
locationEditCell.appendChild(locationYStartField);
|
||||
locationEditCell.appendChild(document.createTextNode(") to ("));
|
||||
locationEditCell.appendChild(locationXEndField);
|
||||
locationEditCell.appendChild(document.createTextNode(", "));
|
||||
locationEditCell.appendChild(locationYEndField);
|
||||
locationEditCell.appendChild(document.createTextNode(")"));
|
||||
|
||||
locationCell.appendChild(locationReadCell);
|
||||
locationCell.appendChild(locationEditCell);
|
||||
|
||||
const previewCell = document.createElement("td");
|
||||
const previewReadCell = document.createElement("div");
|
||||
previewReadCell.id = `template-list-preview-${index}`;
|
||||
previewReadCell.classList.add("template-data-view");
|
||||
const previewLink = document.createElement("a");
|
||||
previewLink.href = `javascript:showPreview(${index})`;
|
||||
previewLink.innerText = "Preview";
|
||||
previewReadCell.appendChild(previewLink);
|
||||
const previewEditCell = document.createElement("div");
|
||||
previewEditCell.classList.add("template-data-edit", "hidden");
|
||||
const imageEditField = document.createElement("input");
|
||||
imageEditField.name = "image";
|
||||
imageEditField.type = "file";
|
||||
imageEditField.accept = "image/png";
|
||||
imageEditField.form = editForm.id;
|
||||
previewEditCell.appendChild(imageEditField);
|
||||
previewCell.appendChild(previewReadCell);
|
||||
previewCell.appendChild(previewEditCell);
|
||||
|
||||
const editCell = document.createElement("td");
|
||||
const editReadCell = document.createElement("div");
|
||||
editReadCell.classList.add("template-data-view");
|
||||
const switchToEditButton = document.createElement("button");
|
||||
switchToEditButton.type = "button";
|
||||
switchToEditButton.innerText = "Edit";
|
||||
editReadCell.appendChild(switchToEditButton);
|
||||
const editEditCell = document.createElement("div");
|
||||
editEditCell.classList.add("template-data-edit", "hidden");
|
||||
const editSubmitButton = document.createElement("button");
|
||||
editSubmitButton.type = "submit";
|
||||
editSubmitButton.innerText = "Submit";
|
||||
const editErrors = document.createElement("ul");
|
||||
editErrors.id = `template-data-edit-errors-${index}`;
|
||||
editErrors.classList.add("template-data-edit-errors");
|
||||
editForm.appendChild(editSubmitButton);
|
||||
editForm.appendChild(editErrors);
|
||||
editEditCell.appendChild(editForm);
|
||||
editCell.appendChild(editReadCell);
|
||||
editCell.appendChild(editEditCell);
|
||||
|
||||
const templateRow = document.createElement("tr");
|
||||
templateRow.id = `template-list-data-${index}`;
|
||||
templateRow.appendChild(nameCell);
|
||||
templateRow.appendChild(descriptionCell);
|
||||
templateRow.appendChild(attributionCell);
|
||||
templateRow.appendChild(cropCell);
|
||||
templateRow.appendChild(locationCell);
|
||||
templateRow.appendChild(previewCell);
|
||||
templateRow.appendChild(editCell);
|
||||
|
||||
switchToEditButton.addEventListener("click", (event) => {
|
||||
for (const element of templateRow.getElementsByClassName("template-data-view")) {
|
||||
element.classList.add("hidden");
|
||||
}
|
||||
for (const element of templateRow.getElementsByClassName("template-data-edit")) {
|
||||
element.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
templateRow.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
editErrors.innerHTML = "";
|
||||
|
||||
const name = nameEditField.value;
|
||||
|
||||
const description = descriptionEditField.value;
|
||||
const attribution = attributionEditField.value;
|
||||
|
||||
const cropXStart = parseInt(cropXStartField.value, 10);
|
||||
const cropYStart = parseInt(cropYStartField.value, 10);
|
||||
const cropXEnd = parseInt(cropXEndField.value, 10);
|
||||
const cropYEnd = parseInt(cropYEndField.value, 10);
|
||||
const locXStart = parseInt(locationXStartField.value, 10);
|
||||
const locYStart = parseInt(locationYStartField.value, 10);
|
||||
const locXEnd = parseInt(locationXEndField.value, 10);
|
||||
const locYEnd = parseInt(locationYEndField.value, 10);
|
||||
|
||||
if (
|
||||
isNaN(cropXStart) ||
|
||||
isNaN(cropYStart) ||
|
||||
isNaN(cropXEnd) ||
|
||||
isNaN(cropYEnd) ||
|
||||
isNaN(locXStart) ||
|
||||
isNaN(locXEnd) ||
|
||||
isNaN(locYStart) ||
|
||||
isNaN(locYEnd)
|
||||
) {
|
||||
const parseNumbersError = document.createElement("li");
|
||||
parseNumbersError.innerText = "All crop and location information must be entered";
|
||||
editErrors.appendChild(parseNumbersError);
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
name: name,
|
||||
description: description,
|
||||
attribution: attribution,
|
||||
crop: [cropXStart, cropYStart, cropXEnd, cropYEnd],
|
||||
location: [locXStart, locYStart, locXEnd, locYEnd],
|
||||
};
|
||||
|
||||
const imageFiles = imageEditField.files;
|
||||
if (imageFiles.length > 0) {
|
||||
const fileReader = new FileReader();
|
||||
const fileReaderCompletePromise = new Promise((resolve, reject) => {
|
||||
fileReader.addEventListener("loadend", (event) => resolve());
|
||||
});
|
||||
fileReader.readAsDataURL(imageFiles[0]);
|
||||
await fileReaderCompletePromise;
|
||||
|
||||
const imageDataURL = fileReader.result;
|
||||
if (imageDataURL.startsWith("data:image/png;base64,")) {
|
||||
submitData.image = imageDataURL.substring(22);
|
||||
} else {
|
||||
const imageError = document.createElement("li");
|
||||
imageError.innerText = "Failed to process image as PNG";
|
||||
editErrors.appendChild(imageError);
|
||||
}
|
||||
}
|
||||
if (googleUser) {
|
||||
submitData.token = googleUser.getAuthResponse().id_token;
|
||||
}
|
||||
|
||||
if (editErrors.hasChildNodes()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const origName = templateData[index].name;
|
||||
const encodedName = encodeURIComponent(origName);
|
||||
const submitResponse = await fetch(`/thrimshim/update-template/${encodedName}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(submitData),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!submitResponse.ok) {
|
||||
const submitError = document.createElement("li");
|
||||
submitError.innerText = await submitResponse.text();
|
||||
editErrors.appendChild(submitError);
|
||||
return;
|
||||
}
|
||||
|
||||
templateData[index].name = name;
|
||||
if (submitData.hasOwnProperty("image")) {
|
||||
templateData[index].image = submitData.image;
|
||||
}
|
||||
templateData[index].description = description;
|
||||
templateData[index].attribution = attribution;
|
||||
templateData[index].crop = submitData.crop;
|
||||
templateData[index].location = submitData.location;
|
||||
|
||||
const templateDOM = generateTemplateDOM(index, templateData[index]);
|
||||
templateRow.replaceWith(templateDOM);
|
||||
});
|
||||
|
||||
return templateRow;
|
||||
}
|
||||
|
||||
function addTemplate(index, template) {
|
||||
const templateDOM = generateTemplateDOM(index, template);
|
||||
document.getElementById("template-list-data").appendChild(templateDOM);
|
||||
}
|
||||
|
||||
function setCoordNumberFieldProps(field, direction) {
|
||||
field.type = "number";
|
||||
field.placeholder = direction;
|
||||
field.min = 0;
|
||||
field.step = 1;
|
||||
field.classList.add("template-coord");
|
||||
}
|
||||
|
||||
function showPreview(index) {
|
||||
const template = templateData[index];
|
||||
const previewCell = document.getElementById(`template-list-preview-${index}`);
|
||||
if (!previewCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewContents = document.createElement("img");
|
||||
previewContents.classList.add("template-list-preview");
|
||||
previewContents.src = `/thrimshim/template/${template.name}.png`;
|
||||
|
||||
previewCell.innerHTML = "";
|
||||
previewCell.appendChild(previewContents);
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export enum TimeType {
|
||||
UTC,
|
||||
BusTime,
|
||||
TimeAgo,
|
||||
}
|
||||
|
||||
export function dateTimeFromWubloaderTime(wubloaderTime: string): DateTime | null {
|
||||
const dt = DateTime.fromISO(wubloaderTime, { zone: "UTC" });
|
||||
if (dt.isValid) {
|
||||
return dt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function wubloaderTimeFromDateTime(dateTime: DateTime): string {
|
||||
// Not using ISO here because Luxon doesn't give us a quick way to print an ISO8601 string with no offset.
|
||||
return dateTime.toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
|
||||
}
|
||||
|
||||
class DateTimeMathObject {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
function dateTimeMathObjectFromBusTime(busTime: string): DateTimeMathObject | null {
|
||||
// We need to handle inputs like "-0:10:15" in a way that consistently makes the time negative.
|
||||
// Since we can't assign the negative sign to any particular part, we'll check for the whole thing here.
|
||||
let direction = 1;
|
||||
if (busTime.startsWith("-")) {
|
||||
busTime = busTime.slice(1);
|
||||
direction = -1;
|
||||
}
|
||||
|
||||
const parts = busTime.split(":", 3);
|
||||
const hours = parseInt(parts[0], 10) * direction;
|
||||
const minutes = parts.length > 1 ? parseInt(parts[1], 10) * direction : 0;
|
||||
const seconds = parts.length > 2 ? +parts[2] * direction : 0;
|
||||
return { hours: hours, minutes: minutes, seconds: seconds };
|
||||
}
|
||||
|
||||
export function dateTimeFromBusTime(busStartTime: DateTime, busTime: string): DateTime | null {
|
||||
const busMathObject = dateTimeMathObjectFromBusTime(busTime);
|
||||
if (busMathObject === null) {
|
||||
return null;
|
||||
}
|
||||
return busStartTime.plus(busMathObject);
|
||||
}
|
||||
|
||||
export function busTimeFromDateTime(busStartTime: DateTime, time: DateTime): string {
|
||||
const diff = time.diff(busStartTime);
|
||||
if (diff.milliseconds < 0) {
|
||||
const negativeInterval = diff.negate();
|
||||
return `-${negativeInterval.toFormat("hh:mm:ss.SSS")}`;
|
||||
}
|
||||
return diff.toFormat("hh:mm:ss.SSS");
|
||||
}
|
||||
|
||||
export function dateTimeFromTimeAgo(timeAgo: string): DateTime | null {
|
||||
const parts = timeAgo.split(":");
|
||||
const properties = ["hours", "minutes", "seconds"];
|
||||
const mathObj = {};
|
||||
|
||||
while (parts.length > 0) {
|
||||
const nextPart = parts.pop();
|
||||
if (properties.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const nextProp = properties.pop();
|
||||
const partNumber = +nextPart;
|
||||
if (isNaN(partNumber)) {
|
||||
return null;
|
||||
}
|
||||
mathObj[nextProp] = partNumber;
|
||||
}
|
||||
|
||||
const now = DateTime.utc();
|
||||
return now.minus(mathObj);
|
||||
}
|
||||
|
||||
export function timeAgoFromDateTime(dateTime: DateTime): string {
|
||||
const currentTime = DateTime.utc();
|
||||
const interval = currentTime.diff(dateTime, "seconds");
|
||||
let timeAgoSeconds = interval.seconds;
|
||||
|
||||
let negative = "";
|
||||
if (timeAgoSeconds < 0) {
|
||||
negative = "-";
|
||||
timeAgoSeconds = -timeAgoSeconds;
|
||||
}
|
||||
|
||||
const seconds = Math.floor((timeAgoSeconds % 60) * 1000) / 1000;
|
||||
const secondsString = seconds < 10 ? `0${seconds}` : seconds.toString();
|
||||
const minutes = (timeAgoSeconds / 60) % 60 | 0;
|
||||
const minutesString = minutes < 10 ? `0${minutes}` : minutes.toString();
|
||||
const hours = Math.floor(timeAgoSeconds / 3600);
|
||||
|
||||
return `${negative}${hours}:${minutesString}:${secondsString}`;
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import { Component } from "solid-js";
|
||||
|
||||
export let googleUser: any = null;
|
||||
declare var gapi: any; // This is a global we use from the Google Sign In script
|
||||
|
||||
function googleOnSignIn(googleUserData) {
|
||||
googleUser = googleUserData;
|
||||
|
||||
const signInElem = document.getElementById("google-auth-sign-in");
|
||||
if (signInElem) {
|
||||
signInElem.classList.remove("hidden");
|
||||
}
|
||||
const signOutElem = document.getElementById("google-auth-sign-out");
|
||||
if (signOutElem) {
|
||||
signOutElem.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
async function googleSignOut() {
|
||||
if (googleUser) {
|
||||
googleUser = null;
|
||||
await gapi.auth2.getAuthInstance().signOut();
|
||||
|
||||
const signInElem = document.getElementById("google-auth-sign-in");
|
||||
if (signInElem) {
|
||||
signInElem.classList.add("hidden");
|
||||
}
|
||||
const signOutElem = document.getElementById("google-auth-sign-out");
|
||||
if (signOutElem) {
|
||||
signOutElem.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The googleOnSignIn amd googleSignOut functions need to be available to the global scope for Google code to invoke it
|
||||
(window as any).googleOnSignIn = googleOnSignIn;
|
||||
(window as any).googleSignOut = googleSignOut;
|
||||
|
||||
export const GoogleSignIn: Component = () => {
|
||||
return (
|
||||
<div>
|
||||
<div id="google-auth-sign-in" class="g-signin2" data-onsuccess="googleOnSignIn"></div>
|
||||
<a href="javascript:googleSignOut" id="google-auth-sign-out" class="hidden">
|
||||
Sign Out of Google Account
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,11 @@
|
||||
.streamTimeSettings {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.streamTimeSettingLabel {
|
||||
margin-right: 3px;
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
// Used to make the controls appear below the video player
|
||||
media-player {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
media-provider, media-captions, video {
|
||||
max-width: 100%;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
// Used to make the controls appear below the video player
|
||||
media-player:not([data-fullscreen]) media-controls {
|
||||
position: relative;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
media-controls-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
media-volume-slider {
|
||||
flex-basis: 100px;
|
||||
}
|
||||
|
||||
.vds-slider-track-fill {
|
||||
background-color: #f6f6f6;
|
||||
}
|
@ -0,0 +1,396 @@
|
||||
import {
|
||||
Accessor,
|
||||
Component,
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Setter,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
TimeType,
|
||||
wubloaderTimeFromDateTime,
|
||||
busTimeFromDateTime,
|
||||
timeAgoFromDateTime,
|
||||
dateTimeFromWubloaderTime,
|
||||
dateTimeFromBusTime,
|
||||
dateTimeFromTimeAgo,
|
||||
} from "./convertTime";
|
||||
import styles from "./video.module.scss";
|
||||
import "./video.scss";
|
||||
import { MediaPlayerElement } from "vidstack/elements";
|
||||
|
||||
import "vidstack/icons";
|
||||
import "vidstack/player/styles/default/theme.css";
|
||||
import "vidstack/player/styles/default/layouts/video.css";
|
||||
import "vidstack/player";
|
||||
import "vidstack/player/layouts/default";
|
||||
import "vidstack/player/ui";
|
||||
|
||||
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>;
|
||||
setStreamVideoInfo: Setter<StreamVideoInfo>;
|
||||
showTimeRangeLink: boolean;
|
||||
errorList: Accessor<string[]>;
|
||||
setErrorList: Setter<string[]>;
|
||||
}
|
||||
|
||||
export const StreamTimeSettings: Component<StreamTimeSettingsProps> = (props) => {
|
||||
const [timeType, setTimeType] = createSignal<TimeType>(TimeType.UTC);
|
||||
|
||||
const submitHandler = (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
|
||||
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;
|
||||
|
||||
let startTime: DateTime | null = null;
|
||||
let endTime: DateTime | null = null;
|
||||
switch (timeType) {
|
||||
case TimeType.UTC:
|
||||
startTime = dateTimeFromWubloaderTime(startTimeEntered);
|
||||
if (endTimeEntered !== "") {
|
||||
endTime = dateTimeFromWubloaderTime(endTimeEntered);
|
||||
}
|
||||
break;
|
||||
case TimeType.BusTime:
|
||||
startTime = dateTimeFromBusTime(props.busStartTime(), startTimeEntered);
|
||||
if (endTimeEntered !== "") {
|
||||
endTime = dateTimeFromBusTime(props.busStartTime(), endTimeEntered);
|
||||
}
|
||||
break;
|
||||
case TimeType.TimeAgo:
|
||||
startTime = dateTimeFromTimeAgo(startTimeEntered);
|
||||
if (endTimeEntered !== "") {
|
||||
endTime = dateTimeFromTimeAgo(endTimeEntered);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (startTime === null || (endTimeEntered !== "" && endTime === null)) {
|
||||
const error = "A load boundary time could not be parsed. Check the format of your times.";
|
||||
props.setErrorList([...props.errorList(), error]);
|
||||
return;
|
||||
}
|
||||
|
||||
props.setStreamVideoInfo({
|
||||
streamName: streamName,
|
||||
streamStartTime: startTime,
|
||||
streamEndTime: endTime,
|
||||
});
|
||||
};
|
||||
|
||||
const startTimeDisplay = () => {
|
||||
const startTime = props.streamVideoInfo().streamStartTime;
|
||||
switch (timeType()) {
|
||||
case TimeType.UTC:
|
||||
return wubloaderTimeFromDateTime(startTime);
|
||||
case TimeType.BusTime:
|
||||
return busTimeFromDateTime(props.busStartTime(), startTime);
|
||||
case TimeType.TimeAgo:
|
||||
return timeAgoFromDateTime(startTime);
|
||||
}
|
||||
};
|
||||
|
||||
const endTimeDisplay = () => {
|
||||
const endTime = props.streamVideoInfo().streamEndTime;
|
||||
if (endTime === null) {
|
||||
return "";
|
||||
}
|
||||
switch (timeType()) {
|
||||
case TimeType.UTC:
|
||||
return wubloaderTimeFromDateTime(endTime);
|
||||
case TimeType.BusTime:
|
||||
return busTimeFromDateTime(props.busStartTime(), endTime);
|
||||
case TimeType.TimeAgo:
|
||||
return timeAgoFromDateTime(endTime);
|
||||
}
|
||||
};
|
||||
|
||||
const timeRangeLink = () => {
|
||||
const streamInfo = props.streamVideoInfo();
|
||||
const startTime = wubloaderTimeFromDateTime(streamInfo.streamStartTime);
|
||||
const query = new URLSearchParams({
|
||||
stream: streamInfo.streamName,
|
||||
start: startTime,
|
||||
});
|
||||
if (streamInfo.streamEndTime) {
|
||||
const endTime = wubloaderTimeFromDateTime(streamInfo.streamEndTime);
|
||||
query.append("end", endTime);
|
||||
}
|
||||
return `?${query}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submitHandler} class={styles.streamTimeSettings}>
|
||||
<label>
|
||||
<span class={styles.streamTimeSettingLabel}>Stream</span>
|
||||
<input type="text" name="stream" value={props.streamVideoInfo().streamName} />
|
||||
</label>
|
||||
<label>
|
||||
<span class={styles.streamTimeSettingLabel}>Start Time</span>
|
||||
<input type="text" name="start-time" value={startTimeDisplay()} />
|
||||
</label>
|
||||
<label>
|
||||
<span class={styles.streamTimeSettingLabel}>End Time</span>
|
||||
<input type="text" name="end-time" value={endTimeDisplay()} />
|
||||
</label>
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="time-type"
|
||||
value={TimeType.UTC}
|
||||
checked={timeType() === TimeType.UTC}
|
||||
onClick={(event) => setTimeType(TimeType.UTC)}
|
||||
/>
|
||||
UTC
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="time-type"
|
||||
value={TimeType.BusTime}
|
||||
checked={timeType() === TimeType.BusTime}
|
||||
onClick={(event) => setTimeType(TimeType.BusTime)}
|
||||
/>
|
||||
Bus Time
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="time-type"
|
||||
value={TimeType.TimeAgo}
|
||||
checked={timeType() === TimeType.TimeAgo}
|
||||
onClick={(event) => setTimeType(TimeType.TimeAgo)}
|
||||
/>
|
||||
Time Ago
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit">Update Time Range</button>
|
||||
</div>
|
||||
<Show when={props.showTimeRangeLink}>
|
||||
<div>
|
||||
<a href={timeRangeLink()}>Link to this time range</a>
|
||||
</div>
|
||||
</Show>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
src: Accessor<string>;
|
||||
}
|
||||
|
||||
export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
|
||||
let [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>();
|
||||
createEffect(() => {
|
||||
const player = mediaPlayer();
|
||||
const srcURL = props.src();
|
||||
player.src = srcURL;
|
||||
});
|
||||
|
||||
let [playerTime, setPlayerTime] = createSignal(0);
|
||||
let [duration, setDuration] = createSignal(0);
|
||||
|
||||
onMount(() => {
|
||||
const player = mediaPlayer();
|
||||
player.subscribe(({ currentTime, duration }) => {
|
||||
setPlayerTime(currentTime);
|
||||
setDuration(duration);
|
||||
});
|
||||
player.streamType = "on-demand";
|
||||
});
|
||||
|
||||
// The <media-time> elements provided by vidstack don't show milliseconds, so
|
||||
// we need to run our own for millisecond display.
|
||||
const formatTime = (time: number) => {
|
||||
const hours = Math.floor(time / 3600);
|
||||
const minutes = Math.floor((time / 60) % 60);
|
||||
const milliseconds = Math.floor((time % 1) * 1000);
|
||||
const seconds = Math.floor(time % 60);
|
||||
|
||||
const minutesDisplay = minutes.toString().padStart(2, "0");
|
||||
const secondsDisplay = seconds.toString().padStart(2, "0");
|
||||
const millisecondsDisplay = milliseconds.toString().padStart(3, "0");
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutesDisplay}:${secondsDisplay}.${millisecondsDisplay}`;
|
||||
}
|
||||
return `${hours}:${minutesDisplay}:${secondsDisplay}.${millisecondsDisplay}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<media-player
|
||||
src={props.src()}
|
||||
ref={setMediaPlayer}
|
||||
preload="auto"
|
||||
controlsDelay={0}
|
||||
storage="thrimbletrimmer"
|
||||
>
|
||||
<media-provider
|
||||
onClick={(event) => {
|
||||
const player = mediaPlayer();
|
||||
if (player.paused) {
|
||||
player.play(event);
|
||||
} else {
|
||||
player.pause(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<media-captions class="vds-captions" />
|
||||
<media-controls class="vds-controls">
|
||||
<media-controls-group class="vds-controls-group">
|
||||
<media-tooltip>
|
||||
<media-tooltip-trigger>
|
||||
<media-play-button class="vds-button">
|
||||
<media-icon type="play" class="vds-play-icon" />
|
||||
<media-icon type="pause" class="vds-pause-icon" />
|
||||
</media-play-button>
|
||||
</media-tooltip-trigger>
|
||||
<media-tooltip-content class="vds-tooltip-content" placement="top">
|
||||
<span class="vds-play-tooltip-text">Play</span>
|
||||
<span class="vds-pause-tooltip-text">Pause</span>
|
||||
</media-tooltip-content>
|
||||
</media-tooltip>
|
||||
|
||||
<media-tooltip>
|
||||
<media-tooltip-trigger>
|
||||
<media-mute-button class="vds-button">
|
||||
<media-icon type="mute" class="vds-mute-icon" />
|
||||
<media-icon type="volume-low" class="vds-volume-low-icon" />
|
||||
<media-icon type="volume-high" class="vds-volume-high-icon" />
|
||||
</media-mute-button>
|
||||
</media-tooltip-trigger>
|
||||
<media-tooltip-content class="vds-tooltip-content" placement="top">
|
||||
<span class="vds-mute-tooltip-text">Unmute</span>
|
||||
<span class="vds-unmute-tooltip-text">Mute</span>
|
||||
</media-tooltip-content>
|
||||
</media-tooltip>
|
||||
|
||||
<media-volume-slider class="vds-slider">
|
||||
<div class="vds-slider-track"></div>
|
||||
<div class="vds-slider-track vds-slider-track-fill"></div>
|
||||
<media-slider-preview class="vds-slider-preview">
|
||||
<media-slider-value class="vds-slider-value" />
|
||||
</media-slider-preview>
|
||||
<div class="vds-slider-thumb"></div>
|
||||
</media-volume-slider>
|
||||
|
||||
<div>
|
||||
<span>{formatTime(playerTime())}</span>
|
||||
<span class="vds-time-divider">/</span>
|
||||
<span>{formatTime(duration())}</span>
|
||||
</div>
|
||||
|
||||
<div class="vds-controls-spacer"></div>
|
||||
|
||||
<media-tooltip>
|
||||
<media-tooltip-trigger>
|
||||
<media-caption-button class="vds-button">
|
||||
<media-icon class="vds-cc-on-icon" type="closed-captions-on" />
|
||||
<media-icon class="vds-cc-off-icon" type="closed-captions" />
|
||||
</media-caption-button>
|
||||
</media-tooltip-trigger>
|
||||
<media-tooltip-content class="vds-tooltip-content" placement="top">
|
||||
<span class="vds-cc-on-tooltip-text">Turn Closed Captions Off</span>
|
||||
<span class="vds-cc-off-tooltip-text">Turn Closed Captions On</span>
|
||||
</media-tooltip-content>
|
||||
</media-tooltip>
|
||||
|
||||
<media-tooltip>
|
||||
<media-tooltip-trigger>
|
||||
<media-fullscreen-button class="vds-button">
|
||||
<media-icon class="vds-fs-enter-icon" type="fullscreen" />
|
||||
<media-icon class="vds-fs-exit-icon" type="fullscreen-exit" />
|
||||
</media-fullscreen-button>
|
||||
</media-tooltip-trigger>
|
||||
<media-tooltip-content class="vds-tooltip-content" placement="top end">
|
||||
<span class="vds-fs-enter-tooltip-text">Enter Fullscreen</span>
|
||||
<span class="vds-fs-exit-tooltip-text">Exit Fullscreen</span>
|
||||
</media-tooltip-content>
|
||||
</media-tooltip>
|
||||
</media-controls-group>
|
||||
<media-controls-group class="vds-controls-group">
|
||||
<media-time-slider class="vds-time-slider vds-slider">
|
||||
<media-slider-chapters class="vds-slider-chapters">
|
||||
<template>
|
||||
<div class="vds-slider-chapter">
|
||||
<div class="vds-slider-track"></div>
|
||||
<div class="vds-slider-track vds-slider-track-fill"></div>
|
||||
</div>
|
||||
</template>
|
||||
</media-slider-chapters>
|
||||
<media-slider-preview class="vds-slider-preview">
|
||||
<media-slider-value class="vds-slider-value" />
|
||||
</media-slider-preview>
|
||||
</media-time-slider>
|
||||
</media-controls-group>
|
||||
</media-controls>
|
||||
</media-player>
|
||||
);
|
||||
};
|
||||
|
||||
export interface KeyboardShortcutProps {
|
||||
includeEditorShortcuts: boolean;
|
||||
}
|
||||
|
||||
export const KeyboardShortcuts: Component<KeyboardShortcutProps> = (
|
||||
props: KeyboardShortcutProps,
|
||||
) => {
|
||||
return (
|
||||
<details>
|
||||
<summary>Keyboard Shortcuts</summary>
|
||||
<ul>
|
||||
<li>Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)</li>
|
||||
<li>K or Space: Toggle pause</li>
|
||||
<li>M: Toggle mute</li>
|
||||
<li>J: Back 10 seconds</li>
|
||||
<li>L: Forward 10 seconds</li>
|
||||
<li>Left arrow: Back 5 seconds</li>
|
||||
<li>Right arrow: Forward 5 seconds</li>
|
||||
<li>Shift+J: Back 1 second</li>
|
||||
<li>Shift+L: Forward 1 second</li>
|
||||
<li>Comma (,): Back 1 frame</li>
|
||||
<li>Period (.): Forward 1 frame</li>
|
||||
<li>Equals (=): Increase playback speed 1 step</li>
|
||||
<li>Hyphen (-): Decrease playback speed 1 step</li>
|
||||
<li>Shift+=: 2x or maximum playback speed</li>
|
||||
<li>Shift+-: Minimum playback speed</li>
|
||||
<li>Backspace: Reset playback speed to 1x</li>
|
||||
<Show when={props.includeEditorShortcuts}>
|
||||
<li>
|
||||
Left bracket ([): Set start point for active range (indicated by arrow) to current video
|
||||
time
|
||||
</li>
|
||||
<li>Right bracket (]): Set end point for active range to current video time</li>
|
||||
<li>O: Set active range one above current active range</li>
|
||||
<li>
|
||||
P: Set active range one below current active range, adding a new range if the current
|
||||
range is the last one
|
||||
</li>
|
||||
</Show>
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
import "./globalStyle.scss";
|
||||
import { render } from "solid-js/web";
|
||||
import Editor from "./editor/Editor";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
render(() => <Editor />, root!);
|
@ -0,0 +1,7 @@
|
||||
import { Component } from "solid-js";
|
||||
|
||||
const Editor: Component = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default Editor;
|
@ -0,0 +1,46 @@
|
||||
body {
|
||||
// Firefox has a weird default font, which is a different size from the one in Chrome
|
||||
// and makes some renderings bad.
|
||||
font-family: "Arial", sans-serif;
|
||||
|
||||
background: #222;
|
||||
color: #fff;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ccf;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background: #222;
|
||||
color: #fff;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
textarea {
|
||||
// Text areas look better with the same borders as input fields.
|
||||
border-style: inset;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
a,
|
||||
.click {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import "./globalStyle.scss";
|
||||
import { render } from "solid-js/web";
|
||||
import { Restreamer } from "./restreamer/Restreamer";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
render(() => <Restreamer />, root!);
|
@ -0,0 +1,21 @@
|
||||
.errorList {
|
||||
color: #f33;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.errorList > div {
|
||||
border-bottom: 1px solid #f33;
|
||||
background: #300;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.errorRemoveLink {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.keyboardShortcutHelp {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
import {
|
||||
Accessor,
|
||||
Component,
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
Setter,
|
||||
Show,
|
||||
Suspense,
|
||||
} from "solid-js";
|
||||
import { DateTime } from "luxon";
|
||||
import styles from "./Restreamer.module.scss";
|
||||
import { dateTimeFromWubloaderTime, wubloaderTimeFromDateTime } from "../common/convertTime";
|
||||
import {
|
||||
KeyboardShortcuts,
|
||||
StreamTimeSettings,
|
||||
StreamVideoInfo,
|
||||
VideoPlayer,
|
||||
} from "../common/video";
|
||||
|
||||
export interface DefaultsData {
|
||||
video_channel: string;
|
||||
bustime_start: string;
|
||||
title_prefix: string;
|
||||
title_max_length: string;
|
||||
upload_locations: string[];
|
||||
}
|
||||
|
||||
export const Restreamer: Component = () => {
|
||||
const [pageErrors, setPageErrors] = createSignal<string[]>([]);
|
||||
const [defaultsData] = createResource<DefaultsData | null>(
|
||||
async (source, { value, refetching }) => {
|
||||
const response = await fetch("/thrimshim/defaults");
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
);
|
||||
|
||||
const busStartTime = () => {
|
||||
const defaults = defaultsData();
|
||||
if (defaults && defaults.hasOwnProperty("bustime_start")) {
|
||||
return dateTimeFromWubloaderTime(defaults.bustime_start);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const now = DateTime.utc();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul class={styles.errorList}>
|
||||
<For each={pageErrors()}>
|
||||
{(error: string, index: Accessor<number>) => (
|
||||
<li>
|
||||
{error}
|
||||
<a class={styles.errorRemoveLink}>[X]</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<div class={styles.keyboardShortcutHelp}>
|
||||
<KeyboardShortcuts includeEditorShortcuts={false} />
|
||||
</div>
|
||||
<Suspense>
|
||||
<Show when={defaultsData()}>
|
||||
<RestreamerWithDefaults
|
||||
defaults={defaultsData()}
|
||||
errorList={pageErrors}
|
||||
setErrorList={setPageErrors}
|
||||
/>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface RestreamerDefaultProps {
|
||||
defaults: DefaultsData;
|
||||
errorList: Accessor<string[]>;
|
||||
setErrorList: Setter<string[]>;
|
||||
}
|
||||
|
||||
const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
|
||||
const [busStartTime, setBusStartTime] = createSignal<DateTime>(
|
||||
dateTimeFromWubloaderTime(props.defaults.bustime_start),
|
||||
);
|
||||
const [streamVideoInfo, setStreamVideoInfo] = createSignal<StreamVideoInfo>({
|
||||
streamName: props.defaults.video_channel,
|
||||
streamStartTime: DateTime.utc().minus({ minutes: 10 }),
|
||||
streamEndTime: null,
|
||||
});
|
||||
|
||||
const videoURL = () => {
|
||||
const streamInfo = streamVideoInfo();
|
||||
const startTime = wubloaderTimeFromDateTime(streamInfo.streamStartTime);
|
||||
const query = new URLSearchParams({ start: startTime });
|
||||
if (streamInfo.streamEndTime) {
|
||||
const endTime = wubloaderTimeFromDateTime(streamInfo.streamEndTime);
|
||||
query.append("end", endTime);
|
||||
}
|
||||
const queryString = query.toString();
|
||||
let url = `/playlist/${streamInfo.streamName}.m3u8`;
|
||||
if (queryString !== "") {
|
||||
url += `?${queryString}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StreamTimeSettings
|
||||
busStartTime={busStartTime}
|
||||
streamVideoInfo={streamVideoInfo}
|
||||
setStreamVideoInfo={setStreamVideoInfo}
|
||||
showTimeRangeLink={false}
|
||||
errorList={props.errorList}
|
||||
setErrorList={props.setErrorList}
|
||||
/>
|
||||
<VideoPlayer src={videoURL} />
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
import "./globalStyle.scss";
|
||||
import { render } from "solid-js/web";
|
||||
import ThumbnailManager from "./thumbnails/ThumbnailManager";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
render(() => <ThumbnailManager />, root!);
|
@ -0,0 +1,47 @@
|
||||
.templatesList {
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content max-content max-content max-content max-content max-content;
|
||||
gap: 0px;
|
||||
border-left: 1px solid #000;
|
||||
}
|
||||
|
||||
.templatesListHeader > div {
|
||||
border-top: 1px solid #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.templatesListRow {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.templatesListRow > div {
|
||||
border-right: 1px solid #000;
|
||||
border-bottom: 1px solid #000;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.templateCoord {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.templateImagePreview {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.templateUpdateErrors {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.newTemplateFormFields {
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.newTemplateFieldLabelContainer {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.newTemplateMidText {
|
||||
margin: 2px;
|
||||
}
|
@ -0,0 +1,479 @@
|
||||
import { Accessor, Component, createSignal, For, Index, onMount, Setter, Show } from "solid-js";
|
||||
import { GoogleSignIn, googleUser } from "../common/googleAuth";
|
||||
import styles from "./ThumbnailManager.module.scss";
|
||||
|
||||
class Coordinate {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
class Template {
|
||||
name: string;
|
||||
description: string;
|
||||
attribution: string;
|
||||
cropStart: Coordinate;
|
||||
cropEnd: Coordinate;
|
||||
locationStart: Coordinate;
|
||||
locationEnd: Coordinate;
|
||||
}
|
||||
|
||||
const ThumbnailManager: Component = () => {
|
||||
const [templates, setTemplates] = createSignal<Template[]>([]);
|
||||
const [newTemplateErrors, setNewTemplateErrors] = createSignal<string[]>([]);
|
||||
|
||||
onMount(async () => {
|
||||
const templateDataResponse = await fetch("/thrimshim/templates");
|
||||
if (!templateDataResponse.ok) {
|
||||
return;
|
||||
}
|
||||
const templateData = await templateDataResponse.json();
|
||||
const templateList: Template[] = [];
|
||||
for (const template of templateData) {
|
||||
const cropStart = { x: template.crop[0], y: template.crop[1] };
|
||||
const cropEnd = { x: template.crop[2], y: template.crop[3] };
|
||||
const locationStart = { x: template.location[0], y: template.location[1] };
|
||||
const locationEnd = { x: template.location[2], y: template.location[3] };
|
||||
|
||||
templateList.push({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
attribution: template.attribution,
|
||||
cropStart: cropStart,
|
||||
cropEnd: cropEnd,
|
||||
locationStart: locationStart,
|
||||
locationEnd: locationEnd,
|
||||
});
|
||||
}
|
||||
setTemplates(templateList);
|
||||
});
|
||||
|
||||
const submitHandler = async (
|
||||
origName: string,
|
||||
noImageIsError: boolean,
|
||||
errorList: Accessor<string[]>,
|
||||
setErrorList: Setter<string[]>,
|
||||
event: SubmitEvent,
|
||||
): Promise<Template | null> => {
|
||||
setErrorList([]);
|
||||
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const name = formData.get("name") as string;
|
||||
const description = formData.get("description") as string;
|
||||
const attribution = formData.get("attribution") as string;
|
||||
|
||||
const cropStartX = parseInt(formData.get("cropstartx") as string, 10);
|
||||
const cropStartY = parseInt(formData.get("cropstarty") as string, 10);
|
||||
const cropEndX = parseInt(formData.get("cropendx") as string, 10);
|
||||
const cropEndY = parseInt(formData.get("cropendy") as string, 10);
|
||||
|
||||
const locStartX = parseInt(formData.get("locstartx") as string, 10);
|
||||
const locStartY = parseInt(formData.get("locstarty") as string, 10);
|
||||
const locEndX = parseInt(formData.get("locendx") as string, 10);
|
||||
const locEndY = parseInt(formData.get("locendy") as string, 10);
|
||||
|
||||
if (
|
||||
isNaN(cropStartX) ||
|
||||
isNaN(cropStartY) ||
|
||||
isNaN(cropEndX) ||
|
||||
isNaN(cropEndY) ||
|
||||
isNaN(locStartX) ||
|
||||
isNaN(locStartY) ||
|
||||
isNaN(locEndX) ||
|
||||
isNaN(locEndY)
|
||||
) {
|
||||
setErrorList((errors) => {
|
||||
errors.push("All crop and location information must be entered.");
|
||||
return errors;
|
||||
});
|
||||
}
|
||||
|
||||
const imageFile = formData.get("image") as Blob;
|
||||
const fileReader = new FileReader();
|
||||
const fileReaderCompletePromise = new Promise<void>((resolve, reject) => {
|
||||
fileReader.addEventListener("loadend", (event) => resolve());
|
||||
});
|
||||
fileReader.readAsDataURL(imageFile);
|
||||
|
||||
await fileReaderCompletePromise;
|
||||
|
||||
const submitData = new Map();
|
||||
submitData.set("name", name);
|
||||
submitData.set("description", description);
|
||||
submitData.set("attribution", attribution);
|
||||
submitData.set("crop", [cropStartX, cropStartY, cropEndX, cropEndY]);
|
||||
submitData.set("location", [locStartX, locStartY, locEndX, locEndY]);
|
||||
|
||||
const imageDataURL = fileReader.result as string;
|
||||
if (imageDataURL.startsWith("data:image/png;base64,")) {
|
||||
submitData.set("image", imageDataURL.substring(22));
|
||||
} else if (noImageIsError) {
|
||||
setErrorList((errors) => {
|
||||
errors.push("A PNG image must be selected.");
|
||||
return errors;
|
||||
});
|
||||
}
|
||||
|
||||
if (googleUser) {
|
||||
submitData.set("token", googleUser.getAuthResponse().id_token);
|
||||
}
|
||||
|
||||
if (errorList().length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const submitURL =
|
||||
origName === ""
|
||||
? "/thrimshim/add-template"
|
||||
: `/thrimshim/update-template/${encodeURIComponent(origName)}`;
|
||||
const submitDataJSON = JSON.stringify(Object.fromEntries(submitData));
|
||||
const submitResponse = await fetch(submitURL, {
|
||||
method: "POST",
|
||||
body: submitDataJSON,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!submitResponse.ok) {
|
||||
const errorText = await submitResponse.text();
|
||||
setErrorList((errors) => {
|
||||
errors.push(errorText);
|
||||
return errors;
|
||||
});
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
||||
return {
|
||||
name: name,
|
||||
description: description,
|
||||
attribution: attribution,
|
||||
cropStart: { x: cropStartX, y: cropStartY },
|
||||
cropEnd: { x: cropEndX, y: cropEndY },
|
||||
locationStart: { x: locStartX, y: locStartY },
|
||||
locationEnd: { x: locEndX, y: locEndY },
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class={styles.templatesList}>
|
||||
<div class={`${styles.templatesListRow} ${styles.templatesListHeader}`}>
|
||||
<div>Name</div>
|
||||
<div>Description</div>
|
||||
<div>Attribution</div>
|
||||
<div>Crop Coordiates</div>
|
||||
<div>Location Coordinates</div>
|
||||
<div>Preview</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<Index each={templates()}>
|
||||
{(template: Accessor<Template>, index: number) => {
|
||||
const [formErrors, setFormErrors] = createSignal<string[]>([]);
|
||||
const [displayImagePreview, setDisplayImagePreview] = createSignal(false);
|
||||
const [editing, setEditing] = createSignal(false);
|
||||
|
||||
return (
|
||||
<form
|
||||
class={styles.templatesListRow}
|
||||
onSubmit={async (event) => {
|
||||
const submitData = await submitHandler(
|
||||
template().name,
|
||||
false,
|
||||
formErrors,
|
||||
setFormErrors,
|
||||
event,
|
||||
);
|
||||
if (submitData) {
|
||||
setTemplates((templateList) => {
|
||||
templateList[index] = submitData;
|
||||
return templateList;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={editing()}
|
||||
fallback={
|
||||
<>
|
||||
<div>{template().name}</div>
|
||||
<div>{template().description}</div>
|
||||
<div>{template().attribution}</div>
|
||||
<div>
|
||||
({template().cropStart.x}, {template().cropStart.y}) to (
|
||||
{template().cropEnd.x}, {template().cropEnd.y})
|
||||
</div>
|
||||
<div>
|
||||
({template().locationStart.x}, {template().locationStart.y}) to (
|
||||
{template().locationEnd.x}, {template().locationEnd.y})
|
||||
</div>
|
||||
<div>
|
||||
<Show
|
||||
when={displayImagePreview()}
|
||||
fallback={
|
||||
<a href="#" onClick={(event) => setDisplayImagePreview(true)}>
|
||||
Preview
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<img
|
||||
class={styles.templateImagePreview}
|
||||
src={`/thrimshim/template/${encodeURIComponent(template().name)}.png`}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" onClick={(event) => setEditing(true)}>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div>
|
||||
<input type="text" name="name" value={template().name} />
|
||||
</div>
|
||||
<div>
|
||||
<textarea name="description">{template().description}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" name="attribution" value={template().attribution} />
|
||||
</div>
|
||||
<div>
|
||||
(
|
||||
<input
|
||||
type="number"
|
||||
name="cropstartx"
|
||||
placeholder="X"
|
||||
min={0}
|
||||
step={1}
|
||||
class={styles.templateCoord}
|
||||
value={template().cropStart.x}
|
||||
/>
|
||||
,
|
||||
<input
|
||||
type="number"
|
||||
name="cropstarty"
|
||||
placeholder="Y"
|
||||
min={0}
|
||||
step={1}
|
||||
class={styles.templateCoord}
|
||||
value={template().cropStart.y}
|
||||
/>
|
||||
)
|
||||
<br />
|
||||
(
|
||||
<input
|
||||
type="number"
|
||||
name="cropendx"
|
||||
placeholder="X"
|
||||
min={0}
|
||||
step={1}
|
||||
class={styles.templateCoord}
|
||||
value={template().cropEnd.x}
|
||||
/>
|
||||
,
|
||||
<input
|
||||
type="number"
|
||||
name="cropendy"
|
||||
placeholder="Y"
|
||||
min={0}
|
||||
step={1}
|
||||
class={styles.templateCoord}
|
||||
value={template().cropEnd.y}
|
||||
/>
|
||||
)
|
||||
</div>
|
||||
<div>
|
||||
(
|
||||
<input
|
||||
type="number"
|
||||
name="locstartx"
|
||||
placeholder="X"
|
||||
min={0}
|
||||
step={1}
|
||||
class={styles.templateCoord}
|
||||
value={template().locationStart.x}
|
||||
/>
|
||||
,
|
||||
<input
|
||||
type="number"
|
||||
name="locstarty"
|
||||
placeholder="Y"
|
||||
min={0}
|
||||
step={1}
|
||||
class={styles.templateCoord}
|
||||
value={template().locationStart.y}
|
||||
/>
|
||||
)
|
||||
<br />
|
||||
(
|
||||
<input
|
||||
type="number"
|
||||
name="locendx"
|
||||
placeholder="X"
|
||||
min={0}
|
||||
step={1}
|
||||
class={styles.templateCoord}
|
||||
value={template().locationEnd.x}
|
||||
/>
|
||||
,
|
||||
<input
|
||||
type="number"
|
||||
name="locendy"
|
||||
placeholder="Y"
|
||||
min={0}
|
||||
step={1}
|
||||
class={styles.templateCoord}
|
||||
value={template().locationEnd.y}
|
||||
/>
|
||||
)
|
||||
</div>
|
||||
<div>
|
||||
<input type="file" name="image" accept="image/png" />
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit">Submit</button>
|
||||
<button type="button" onClick={(event) => setEditing(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<ul class={styles.templateUpdateErrors}>
|
||||
<For each={formErrors()}>
|
||||
{(error: string, index: Accessor<number>) => <li>{error}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
</form>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
const submitData = await submitHandler(
|
||||
"",
|
||||
true,
|
||||
newTemplateErrors,
|
||||
setNewTemplateErrors,
|
||||
event,
|
||||
);
|
||||
if (submitData) {
|
||||
setTemplates((templateList) => [...templateList, submitData]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h1>Add New Template</h1>
|
||||
<ul class={styles.templateUpdateErrors}>
|
||||
<For each={newTemplateErrors()}>
|
||||
{(error: string, index: Accessor<number>) => <li>{error}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
<div class={styles.newTemplateFormFields}>
|
||||
<label class={styles.newTemplateFieldLabelContainer}>
|
||||
<span>Name:</span>
|
||||
<input type="text" name="name" />
|
||||
</label>
|
||||
<label class={styles.newTemplateFieldLabelContainer}>
|
||||
<span>Image:</span>
|
||||
<input type="file" name="image" accept="image/png" />
|
||||
</label>
|
||||
<label class={styles.newTemplateFieldLabelContainer}>
|
||||
<span>Description:</span>
|
||||
<textarea name="description"></textarea>
|
||||
</label>
|
||||
<label class={styles.newTemplateFieldLabelContainer}>
|
||||
<span>Attribution:</span>
|
||||
<input type="text" name="attribution" />
|
||||
</label>
|
||||
<span>Crop:</span>
|
||||
<span>
|
||||
<input
|
||||
type="number"
|
||||
class={styles.templateCoord}
|
||||
name="cropstartx"
|
||||
placeholder="X"
|
||||
min={0}
|
||||
step={1}
|
||||
value={182}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class={styles.templateCoord}
|
||||
name="cropstarty"
|
||||
placeholder="Y"
|
||||
min={0}
|
||||
step={1}
|
||||
value={0}
|
||||
/>
|
||||
<span class={styles.newTemplateMidText}>to</span>
|
||||
<input
|
||||
type="number"
|
||||
class={styles.templateCoord}
|
||||
name="cropendx"
|
||||
placeholder="X"
|
||||
min={0}
|
||||
step={1}
|
||||
value={1738}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class={styles.templateCoord}
|
||||
name="cropendy"
|
||||
placeholder="Y"
|
||||
min={0}
|
||||
step={1}
|
||||
value={824}
|
||||
/>
|
||||
</span>
|
||||
<span class={styles.newTemplateInlineLabel}>Location:</span>
|
||||
<span>
|
||||
<input
|
||||
type="number"
|
||||
class={styles.templateCoord}
|
||||
name="locstartx"
|
||||
placeholder="X"
|
||||
min={0}
|
||||
step={1}
|
||||
value={45}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class={styles.templateCoord}
|
||||
name="locstarty"
|
||||
placeholder="Y"
|
||||
min={0}
|
||||
step={1}
|
||||
value={45}
|
||||
/>
|
||||
<span class={styles.newTemplateMidText}>to</span>
|
||||
<input
|
||||
type="number"
|
||||
class={styles.templateCoord}
|
||||
name="locendx"
|
||||
placeholder="X"
|
||||
min={0}
|
||||
step={1}
|
||||
value={1235}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class={styles.templateCoord}
|
||||
name="locendy"
|
||||
placeholder="Y"
|
||||
min={0}
|
||||
step={1}
|
||||
value={675}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<button type="submit">Add Template</button>
|
||||
<button type="reset">Reset</button>
|
||||
</form>
|
||||
<GoogleSignIn />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThumbnailManager;
|
@ -0,0 +1,52 @@
|
||||
import { Accessor, Component, createSignal, onCleanup, onMount } from "solid-js";
|
||||
import { DateTime, Interval } from "luxon";
|
||||
|
||||
interface ClockProps {
|
||||
busStartTime: Accessor<DateTime | null>;
|
||||
}
|
||||
|
||||
const Clock: Component<ClockProps> = (props) => {
|
||||
const [delay, setDelay] = createSignal<number>(10);
|
||||
const [time, setTime] = createSignal<DateTime>(DateTime.utc());
|
||||
const busStartTime = props.busStartTime;
|
||||
|
||||
const timer = setInterval(() => setTime(DateTime.utc()), 250);
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
const timeDisplay = () => {
|
||||
const currentTime = time().minus({ seconds: delay() });
|
||||
const busTime = busStartTime();
|
||||
if (!busTime) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [timeElapsed, sign] =
|
||||
currentTime >= busTime
|
||||
? [Interval.fromDateTimes(busTime, currentTime).toDuration("seconds"), ""]
|
||||
: [Interval.fromDateTimes(currentTime, busTime).toDuration("seconds"), "-"];
|
||||
|
||||
const timeElapsedString = timeElapsed.toFormat("h:mm:ss");
|
||||
return `${sign}${timeElapsedString}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{timeDisplay()}</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
value={delay()}
|
||||
min={0}
|
||||
step={1}
|
||||
onInput={(event) => setDelay(+event.currentTarget.value)}
|
||||
/>
|
||||
 seconds of delay
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Clock;
|
@ -0,0 +1,131 @@
|
||||
import { Accessor, Component, createSignal } from "solid-js";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
dateTimeFromWubloaderTime,
|
||||
dateTimeFromBusTime,
|
||||
dateTimeFromTimeAgo,
|
||||
wubloaderTimeFromDateTime,
|
||||
busTimeFromDateTime,
|
||||
timeAgoFromDateTime,
|
||||
TimeType,
|
||||
} from "../common/convertTime";
|
||||
|
||||
interface TimeConverterProps {
|
||||
busStartTime: Accessor<DateTime | null>;
|
||||
}
|
||||
|
||||
const TimeConverter: Component<TimeConverterProps> = (props) => {
|
||||
const [enteredTime, setEnteredTime] = createSignal<string>("");
|
||||
const [startTimeType, setStartTimeType] = createSignal<TimeType>(TimeType.UTC);
|
||||
const [outputTimeType, setOutputTimeType] = createSignal<TimeType>(TimeType.UTC);
|
||||
|
||||
const outputString = (): string => {
|
||||
const busStartTime = props.busStartTime();
|
||||
if (busStartTime === null) {
|
||||
return "";
|
||||
}
|
||||
const startType = startTimeType();
|
||||
let dateTime: DateTime | null = null;
|
||||
if (startType === TimeType.UTC) {
|
||||
dateTime = dateTimeFromWubloaderTime(enteredTime());
|
||||
} else if (startType === TimeType.BusTime) {
|
||||
dateTime = dateTimeFromBusTime(busStartTime, enteredTime());
|
||||
} else if (startType === TimeType.TimeAgo) {
|
||||
dateTime = dateTimeFromTimeAgo(enteredTime());
|
||||
}
|
||||
if (dateTime === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const outputType = outputTimeType();
|
||||
if (outputType === TimeType.UTC) {
|
||||
return wubloaderTimeFromDateTime(dateTime);
|
||||
}
|
||||
if (outputType === TimeType.BusTime) {
|
||||
return busTimeFromDateTime(busStartTime, dateTime);
|
||||
}
|
||||
if (outputType === TimeType.TimeAgo) {
|
||||
return timeAgoFromDateTime(dateTime);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Convert Times</h1>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Time to convert"
|
||||
value={enteredTime()}
|
||||
onInput={(event) => {
|
||||
setEnteredTime(event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
From:
|
||||
<label>
|
||||
<input
|
||||
name="time-converter-from"
|
||||
type="radio"
|
||||
value={TimeType.UTC}
|
||||
checked={true}
|
||||
onClick={(event) => setStartTimeType(TimeType.UTC)}
|
||||
/>
|
||||
UTC
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
name="time-converter-from"
|
||||
type="radio"
|
||||
value={TimeType.BusTime}
|
||||
onClick={(event) => setStartTimeType(TimeType.BusTime)}
|
||||
/>
|
||||
Bus Time
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
name="time-converter-from"
|
||||
type="radio"
|
||||
value={TimeType.TimeAgo}
|
||||
onClick={(event) => setStartTimeType(TimeType.TimeAgo)}
|
||||
/>
|
||||
Time Ago
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
To:
|
||||
<label>
|
||||
<input
|
||||
name="time-converter-to"
|
||||
type="radio"
|
||||
checked={true}
|
||||
value={TimeType.UTC}
|
||||
onClick={(event) => setOutputTimeType(TimeType.UTC)}
|
||||
/>
|
||||
UTC
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
name="time-converter-to"
|
||||
type="radio"
|
||||
value={TimeType.BusTime}
|
||||
onClick={(event) => setOutputTimeType(TimeType.BusTime)}
|
||||
/>
|
||||
Bus Time
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
name="time-converter-to"
|
||||
type="radio"
|
||||
value={TimeType.TimeAgo}
|
||||
onClick={(event) => setOutputTimeType(TimeType.TimeAgo)}
|
||||
/>
|
||||
Time Ago
|
||||
</label>
|
||||
</div>
|
||||
<div>Converted Time: {outputString()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeConverter;
|
@ -0,0 +1,23 @@
|
||||
import { Component, createSignal, onMount } from "solid-js";
|
||||
import { DateTime } from "luxon";
|
||||
import Clock from "./Clock";
|
||||
import TimeConverter from "./TimeConverter";
|
||||
|
||||
const Utilities: Component = () => {
|
||||
const [busStartTime, setBusStartTime] = createSignal<DateTime | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const dataResponse = await fetch("/thrimshim/defaults");
|
||||
const data = await dataResponse.json();
|
||||
setBusStartTime(DateTime.fromISO(data.bustime_start));
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Clock busStartTime={busStartTime} />
|
||||
<TimeConverter busStartTime={busStartTime} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Utilities;
|
@ -0,0 +1,7 @@
|
||||
import "./globalStyle.scss";
|
||||
import { render } from "solid-js/web";
|
||||
import Utilities from "./utilities/Utilities";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
render(() => <Utilities />, root!);
|
@ -1,173 +0,0 @@
|
||||
.jcrop-widget .jcrop-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
border: 1px rgba(127, 127, 127, 0.8) solid;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8)
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.nw {
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
cursor: nwse-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.w {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: -3px;
|
||||
cursor: ew-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.sw {
|
||||
bottom: -3px;
|
||||
left: -3px;
|
||||
cursor: nesw-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.ne {
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
cursor: nesw-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.e {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: -3px;
|
||||
cursor: ew-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.se {
|
||||
bottom: -3px;
|
||||
right: -3px;
|
||||
cursor: nwse-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.n {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -3px;
|
||||
cursor: ns-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.s {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: -3px;
|
||||
cursor: ns-resize
|
||||
}
|
||||
|
||||
.jcrop-widget.active .jcrop-handle {
|
||||
display: block
|
||||
}
|
||||
|
||||
.jcrop-widget {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px white dashed;
|
||||
opacity: 0.7;
|
||||
background: transparent;
|
||||
transition: opacity 1s;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: move
|
||||
}
|
||||
|
||||
.jcrop-widget:hover {
|
||||
transition: opacity 0.8s;
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
.jcrop-shade {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
transition: opacity 0.4s, background-color 0.7s;
|
||||
position: absolute
|
||||
}
|
||||
|
||||
.jcrop-shade.l {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.jcrop-shade.r {
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.jcrop-shade.t {
|
||||
top: 0px
|
||||
}
|
||||
|
||||
.jcrop-shade.b {
|
||||
bottom: 0px
|
||||
}
|
||||
|
||||
.jcrop-stage {
|
||||
position: relative;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.jcrop-image-stage img {
|
||||
position: absolute;
|
||||
z-index: -1
|
||||
}
|
||||
|
||||
.jcrop-ux-inactive-handles .jcrop-widget .jcrop-handle {
|
||||
display: block
|
||||
}
|
||||
|
||||
.jcrop-widget img {
|
||||
width: 100%;
|
||||
height: auto
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget {
|
||||
opacity: 0.25
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget:hover {
|
||||
transition: opacity 0.4s;
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget:focus {
|
||||
transition: opacity 0.5s;
|
||||
opacity: 1;
|
||||
outline-style: auto;
|
||||
outline-width: 3px;
|
||||
outline-color: rgba(0, 0, 0, 0.3)
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget {
|
||||
opacity: 0.25
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget:hover {
|
||||
opacity: 0.65
|
||||
}
|
||||
|
||||
.jcrop-ux-keep-current .jcrop-widget.active {
|
||||
opacity: 1;
|
||||
outline-style: auto;
|
||||
outline-width: 3px;
|
||||
outline-color: rgba(0, 0, 0, 0.3)
|
||||
}
|
||||
|
||||
.jcrop-ux-no-outline .jcrop-widget {
|
||||
outline: none !important
|
||||
}
|
||||
|
||||
.jcrop-disable.jcrop-stage {
|
||||
opacity: .8
|
||||
}
|
||||
|
||||
.jcrop-disable.jcrop-stage .jcrop-widget {
|
||||
outline: none !important
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=jcrop.css.map */
|
@ -1,496 +0,0 @@
|
||||
body {
|
||||
/* Firefox has a weird default font, which is a different size from the one in Chrome
|
||||
* and makes some renderings bad.
|
||||
*/
|
||||
font-family: "Arial", sans-serif;
|
||||
|
||||
background: #222;
|
||||
color: #fff;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ccf;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background: #222;
|
||||
color: #fff;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
textarea {
|
||||
/* This will look better if it's consistent with input fields */
|
||||
border-style: inset;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
a,
|
||||
.click {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.click {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: #b00;
|
||||
}
|
||||
|
||||
.input-error:focus {
|
||||
outline: #d00 solid 1px;
|
||||
}
|
||||
|
||||
#errors {
|
||||
color: #f33;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#errors > div {
|
||||
border-bottom: 1px solid #f33;
|
||||
background: #300;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.error-dismiss {
|
||||
float: right;
|
||||
}
|
||||
|
||||
#page-container {
|
||||
position: relative;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#page-container > * {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
#editor-help {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: #222;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#stream-time-settings {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
#stream-time-settings > div {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#video {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
#video-controls {
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
#video-controls select {
|
||||
appearance: none;
|
||||
font-size: inherit;
|
||||
background: inherit;
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#video-controls option {
|
||||
background: #222;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#video-controls-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#video-controls-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#video-controls-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#video-controls-volume-level {
|
||||
width: 100px;
|
||||
height: 8px;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#video-controls-playback-position {
|
||||
height: 10px;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* For some reason, there's not a cross-browser way to style <progress> elements.
|
||||
* This should be replaced with a cross-browser way of doing this when possible.
|
||||
* I only implemented WebKit/Blink and Firefox here because if you still use IE,
|
||||
* I quite frankly don't care about you.
|
||||
*/
|
||||
|
||||
/* WEBKIT/BLINK SECTION */
|
||||
#video-controls-volume-level::-webkit-progress-bar {
|
||||
background: #ffffff30;
|
||||
}
|
||||
|
||||
#video-controls-volume-level::-webkit-progress-value {
|
||||
background: #ffffffc0;
|
||||
}
|
||||
|
||||
#video-controls-playback-position::-webkit-progress-bar {
|
||||
background: #ffffff30;
|
||||
}
|
||||
|
||||
#video-controls-playback-position::-webkit-progress-value {
|
||||
background: #ffffffc0;
|
||||
}
|
||||
/* END WEBKIT/BLINK */
|
||||
|
||||
/* FIREFOX SECTION */
|
||||
#video-controls-volume-level {
|
||||
background: #ffffff30;
|
||||
}
|
||||
|
||||
#video-controls-volume-level::-moz-progress-bar {
|
||||
background: #ffffffc0;
|
||||
}
|
||||
|
||||
#video-controls-playback-position {
|
||||
background: #ffffff30;
|
||||
}
|
||||
|
||||
#video-controls-playback-position::-moz-progress-bar {
|
||||
background: #ffffffc0;
|
||||
}
|
||||
/* END FIREFOX */
|
||||
|
||||
#video-controls-playback-position {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#clip-bar {
|
||||
width: 100%;
|
||||
min-height: 7px;
|
||||
background-color: #bbb;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#clip-bar > div {
|
||||
position: absolute;
|
||||
background-color: #d80;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#waveform-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#waveform {
|
||||
width: 100%;
|
||||
|
||||
/* With an unbound height, the waveform can appear a bit away from the video.
|
||||
* The intended effect still works if we scrunch the height a bit, so here's
|
||||
* a height bound for the waveform image.
|
||||
*/
|
||||
max-height: 100px;
|
||||
filter: invert(90%);
|
||||
}
|
||||
|
||||
#waveform-marker {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: #dd8800a0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#range-definitions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.range-transition-duration-section {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.range-transition-duration {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.range-definition-times {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.range-definition-start,
|
||||
.range-definition-end {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.range-definition-between-time-gap {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.range-definition-icon-gap {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
#add-range-definition {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.range-definition-chapter-markers > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
.range-definition-chapter-marker-start-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.range-definition-chapter-marker-start {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.range-definition-chapter-marker-edit-gap {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
input.range-definition-chapter-marker-description {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.add-range-definition-chapter-marker {
|
||||
margin-left: 30px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
#video-info {
|
||||
margin: 5px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-rows: minmax(min-content, max-content) 1.25em 3em minmax(4em, max-content) 1.25em;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#video-info-editor-notes-container {
|
||||
border: 1px solid #666;
|
||||
background-color: #125;
|
||||
grid-column-end: span 2;
|
||||
}
|
||||
|
||||
/* In order to maintain the grid dimensions, when we hide the editors notes (for there not being them),
|
||||
* they still need to take up a grid slot. As such, we replace `display: none` in this context with
|
||||
* an effective equivalent that doesn't remove its rendering entirely.
|
||||
*/
|
||||
#video-info-editor-notes-container.hidden {
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#video-info-title-full {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#video-info-title {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#video-info-title-abbreviated {
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
height: 2em;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
/* For some reason, all this Webkit-specific-looking stuff is required to show ellipses
|
||||
on wrapped text.
|
||||
It also somehow works on Firefox. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-mode-options {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-position {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
#video-info-thumbnail-template-preview-image {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
#video-info-thumbnail-template-video-source-image {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
#video-info-thumbnail-template-overlay-image {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-advanced-crop-flex-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-advanced-crop-flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-advanced-crop-flex-column div {
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
.submission-response-error {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#submission {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
#download {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
#data-correction {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
#data-correction-force-reset-confirm p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.submission-response-pending {
|
||||
color: #cc0;
|
||||
}
|
||||
|
||||
.submission-response-error {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.submission-response-success {
|
||||
color: #0c0;
|
||||
}
|
||||
|
||||
.time-converter-time {
|
||||
display: block;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
#chat-replay {
|
||||
overflow-y: auto;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.chat-replay-message {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-replay-message-time {
|
||||
flex-basis: 110px;
|
||||
color: #ccc;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chat-replay-message-text {
|
||||
flex-basis: 200px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.chat-replay-message-text-action {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-replay-message-system {
|
||||
color: #aaf;
|
||||
}
|
||||
|
||||
.chat-replay-message-text-action .chat-replay-message-reply:not(.chat-replay-message-text-action) {
|
||||
font-style: normal; /* Clear the italics from the action */
|
||||
}
|
||||
|
||||
.chat-replay-message-emote {
|
||||
/*
|
||||
This size is set based on Twitch's 1.0 emote size.
|
||||
This will need to be updated if that changes. (Otherwise, auto-scrolling will break.)
|
||||
*/
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.chat-replay-message-reply {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.chat-replay-message-reply a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-replay-message-cleared {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chat-replay-message-cleared .chat-replay-message-text {
|
||||
text-decoration: line-through;
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table > form {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
#template-list-data {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#template-list-data td {
|
||||
border: 1px solid #000;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.template-list-preview {
|
||||
width: 480px;
|
||||
}
|
||||
|
||||
#template-new-errors,
|
||||
.template-data-edit-errors {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
#template-new-form-fields {
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
#template-new-form-fields > div {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.template-coord {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
#google-authentication {
|
||||
margin-top: 5px;
|
||||
}
|
@ -1,155 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>VST Thumbnail Template Management</title>
|
||||
|
||||
<meta
|
||||
name="google-signin-client_id"
|
||||
content="345276493482-r84m2giavk10glnmqna0lbq8e1hdaus0.apps.googleusercontent.com"
|
||||
/>
|
||||
<script src="https://apis.google.com/js/platform.js?onload=onGLoad" async defer></script>
|
||||
<link rel="stylesheet" href="styles/thumbnails.css" />
|
||||
<script src="scripts/thumbnails.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="template-list">
|
||||
<h1>Template List</h1>
|
||||
<table id="template-list-data">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Attribution</th>
|
||||
<th>Crop Coordinates</th>
|
||||
<th>Location Coordinates</th>
|
||||
<th>Preview</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="template-new">
|
||||
<h1>Add New Template</h1>
|
||||
<ul id="template-new-errors"></ul>
|
||||
<form id="template-new-form">
|
||||
<div id="template-new-form-fields">
|
||||
<div>
|
||||
<label for="template-new-name">Name:</label>
|
||||
<input type="text" name="name" id="template-new-name" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="template-new-image">Image:</label>
|
||||
<input type="file" id="template-new-image" name="image" accept="image/png" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="template-new-description">Description:</label>
|
||||
<textarea id="template-new-description" name="description"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="template-new-attribution">Attribution:</label>
|
||||
<input type="text" id="template-new-attribution" name="attribution" />
|
||||
</div>
|
||||
<div>
|
||||
<span>Crop:</span>
|
||||
<span>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-crop-x-start"
|
||||
name="cropxstart"
|
||||
placeholder="X"
|
||||
min="0"
|
||||
step="1"
|
||||
value="182"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-crop-y-start"
|
||||
name="cropystart"
|
||||
placeholder="Y"
|
||||
min="0"
|
||||
step="1"
|
||||
value="0"
|
||||
/>
|
||||
to
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-crop-x-end"
|
||||
name="cropxend"
|
||||
placeholder="X"
|
||||
min="0"
|
||||
step="1"
|
||||
value="1738"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-crop-y-end"
|
||||
name="cropyend"
|
||||
placeholder="Y"
|
||||
min="0"
|
||||
step="1"
|
||||
value="824"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>Location:</span>
|
||||
<span>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-location-x-start"
|
||||
name="locxstart"
|
||||
placeholder="X"
|
||||
min="0"
|
||||
step="1"
|
||||
value="45"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-location-y-start"
|
||||
name="locystart"
|
||||
placeholder="Y"
|
||||
min="0"
|
||||
step="1"
|
||||
value="45"
|
||||
/>
|
||||
to
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-location-x-end"
|
||||
name="locxend"
|
||||
placeholder="X"
|
||||
min="0"
|
||||
step="1"
|
||||
value="1235"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-location-y-end"
|
||||
name="locyend"
|
||||
placeholder="Y"
|
||||
min="0"
|
||||
step="1"
|
||||
value="675"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Add Template</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="google-authentication">
|
||||
<div id="google-auth-sign-in" class="g-signin2" data-onsuccess="googleOnSignIn"></div>
|
||||
<a href="#" id="google-auth-sign-out" class="hidden">Sign Out of Google Account</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Thrimbletrimmer - Utilities</title>
|
||||
<meta
|
||||
name="google-signin-client_id"
|
||||
content="345276493482-r84m2giavk10glnmqna0lbq8e1hdaus0.apps.googleusercontent.com"
|
||||
/>
|
||||
<script src="https://apis.google.com/js/platform.js?onload=onGLoad" async defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/thumbnails.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"types": ["vidstack/solid", "vite/client"],
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Thrimbletrimmer - Utilities</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/utils.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,24 @@
|
||||
import { fileURLToPath } from "url";
|
||||
import { defineConfig } from "vite";
|
||||
import solidPlugin from "vite-plugin-solid";
|
||||
import devtools from "solid-devtools/vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/thrimbletrimmer/",
|
||||
plugins: [devtools(), solidPlugin()],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
// minify: false, // Uncomment this line if you need to debug unminified code
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: fileURLToPath(new URL("index.html", import.meta.url)),
|
||||
edit: fileURLToPath(new URL("edit.html", import.meta.url)),
|
||||
utils: fileURLToPath(new URL("utils.html", import.meta.url)),
|
||||
thumbnails: fileURLToPath(new URL("thumbnails.html", import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|