Compare commits
33 Commits
4298937535
...
8df629a1be
Author | SHA1 | Date |
---|---|---|
ElementalAlchemist | 8df629a1be | 2 weeks ago |
ElementalAlchemist | 5ee26a8f64 | 2 weeks ago |
ElementalAlchemist | 4185c2dc69 | 2 weeks ago |
ElementalAlchemist | d09bc91de6 | 2 weeks ago |
ElementalAlchemist | f748ad35e0 | 2 weeks ago |
ElementalAlchemist | e6aaddb9eb | 2 weeks ago |
ElementalAlchemist | 3e4f52e9ef | 2 weeks ago |
ElementalAlchemist | 454230f866 | 2 weeks ago |
ElementalAlchemist | 8f88c719ad | 2 weeks ago |
ElementalAlchemist | 1239321b86 | 2 weeks ago |
ElementalAlchemist | 9bfe472468 | 2 weeks ago |
ElementalAlchemist | 15d4a4e3cf | 2 weeks ago |
ElementalAlchemist | d0640bf5c8 | 2 weeks ago |
ElementalAlchemist | 5ee213455d | 2 weeks ago |
ElementalAlchemist | 26785b1958 | 2 weeks ago |
ElementalAlchemist | a50d5d11b2 | 2 weeks ago |
ElementalAlchemist | c54bd3c63f | 2 weeks ago |
ElementalAlchemist | 98ba88fc12 | 2 weeks ago |
ElementalAlchemist | 33cc5d0d66 | 2 weeks ago |
ElementalAlchemist | 1383ae8ccd | 2 weeks ago |
ElementalAlchemist | 1e5718f3a6 | 2 weeks ago |
ElementalAlchemist | 375694875a | 2 weeks ago |
ElementalAlchemist | 1ad9f6f1a4 | 2 weeks ago |
ElementalAlchemist | 9d4bbfd32a | 2 weeks ago |
ElementalAlchemist | f306643a92 | 2 weeks ago |
ElementalAlchemist | 0bb524bf38 | 2 weeks ago |
ElementalAlchemist | d3a7d0bd80 | 2 weeks ago |
Mike Lang | aebbb603fc | 2 weeks ago |
Mike Lang | dc291d4e64 | 2 weeks ago |
Mike Lang | 5db7bcda71 | 2 weeks ago |
Mike Lang | 941050aea9 | 2 weeks ago |
Mike Lang | 6062f0a8ec | 2 weeks ago |
Christopher Usher | 8a0ca215cf | 2 weeks ago |
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
package-lock.json
|
@ -1,4 +1 @@
|
|||||||
scripts/hls.min.js
|
src/external
|
||||||
scripts/luxon.min.js
|
|
||||||
scripts/jcrop.js
|
|
||||||
styles/jcrop.css
|
|
@ -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>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>VST Restreamer</title>
|
<title>Thrimbletrimmer - 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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="errors"></div>
|
<div id="root"></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="chat-replay"></div>
|
<script src="/src/index.tsx" type="module"></script>
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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,129 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { HLSProvider } from "vidstack";
|
||||||
|
import { Fragment } from "hls.js";
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateTimeFromVideoPlayerTime(
|
||||||
|
videoProvider: HLSProvider,
|
||||||
|
videoTime: number,
|
||||||
|
): DateTime | null {
|
||||||
|
// We do a little bit of cheating our way into private data. The standard way of getting this involves
|
||||||
|
// handling events and capturing and saving a fragments array from it, which seems overly cumbersome.
|
||||||
|
const fragments = (videoProvider.instance as any).latencyController.levelDetails
|
||||||
|
.fragments as Fragment[];
|
||||||
|
let fragmentStartTime: number | undefined = undefined;
|
||||||
|
let fragmentStartISOTime: string | undefined = undefined;
|
||||||
|
for (const fragment of fragments) {
|
||||||
|
const fragmentEndTime = fragment.start + fragment.duration;
|
||||||
|
if (videoTime >= fragment.start && videoTime < fragmentEndTime) {
|
||||||
|
fragmentStartTime = fragment.start;
|
||||||
|
fragmentStartISOTime = fragment.rawProgramDateTime;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragmentStartISOTime === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const wubloaderTime = DateTime.fromISO(fragmentStartISOTime);
|
||||||
|
const offset = videoTime - fragmentStartTime;
|
||||||
|
return wubloaderTime.plus({ seconds: offset });
|
||||||
|
}
|
@ -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,30 @@
|
|||||||
|
media-player {
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This level of specificity is required to override player CSS
|
||||||
|
#root > media-player > media-provider {
|
||||||
|
align-items: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,399 @@
|
|||||||
|
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 { HLSProvider } from "vidstack";
|
||||||
|
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>;
|
||||||
|
setPlayerTime: Setter<number>;
|
||||||
|
mediaPlayer: Accessor<MediaPlayerElement>;
|
||||||
|
setMediaPlayer: Setter<MediaPlayerElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
|
||||||
|
createEffect(() => {
|
||||||
|
const player = props.mediaPlayer();
|
||||||
|
const srcURL = props.src();
|
||||||
|
player.src = srcURL;
|
||||||
|
});
|
||||||
|
|
||||||
|
let [playerTime, setPlayerTime] = createSignal(0);
|
||||||
|
let [duration, setDuration] = createSignal(0);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const player = props.mediaPlayer();
|
||||||
|
player.subscribe(({ currentTime, duration }) => {
|
||||||
|
setPlayerTime(currentTime);
|
||||||
|
props.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={props.setMediaPlayer}
|
||||||
|
preload="auto"
|
||||||
|
storage="thrimbletrimmer"
|
||||||
|
>
|
||||||
|
<media-provider
|
||||||
|
onClick={(event) => {
|
||||||
|
const player = props.mediaPlayer();
|
||||||
|
if (player.paused) {
|
||||||
|
player.play(event);
|
||||||
|
} else {
|
||||||
|
player.pause(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<media-captions class="vds-captions" />
|
||||||
|
<media-controls class="vds-controls" hideDelay={0}>
|
||||||
|
<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,45 @@
|
|||||||
|
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;
|
||||||
|
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,25 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoLinks a {
|
||||||
|
margin: 2px;
|
||||||
|
}
|
@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
Accessor,
|
||||||
|
Component,
|
||||||
|
createEffect,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
Setter,
|
||||||
|
Show,
|
||||||
|
Suspense,
|
||||||
|
} from "solid-js";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { HLSProvider } from "vidstack";
|
||||||
|
import { MediaPlayerElement } from "vidstack/elements";
|
||||||
|
import styles from "./Restreamer.module.scss";
|
||||||
|
import {
|
||||||
|
dateTimeFromVideoPlayerTime,
|
||||||
|
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 [playerTime, setPlayerTime] = createSignal<number>(0);
|
||||||
|
const [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadVideoURL = () => {
|
||||||
|
const streamInfo = streamVideoInfo();
|
||||||
|
const startTime = wubloaderTimeFromDateTime(streamInfo.streamStartTime);
|
||||||
|
const params = new URLSearchParams({ type: "smart", start: encodeURIComponent(startTime) });
|
||||||
|
if (streamInfo.streamEndTime) {
|
||||||
|
const endTime = wubloaderTimeFromDateTime(streamInfo.streamEndTime);
|
||||||
|
params.append("end", endTime);
|
||||||
|
}
|
||||||
|
return `/cut/${streamInfo.streamName}/source.ts?${params}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFrameURL = () => {
|
||||||
|
const streamInfo = streamVideoInfo();
|
||||||
|
const player = mediaPlayer();
|
||||||
|
const videoTime = playerTime();
|
||||||
|
const provider = player.provider as HLSProvider;
|
||||||
|
if (!provider) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const currentTime = dateTimeFromVideoPlayerTime(provider, videoTime);
|
||||||
|
if (currentTime === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const wubloaderTime = wubloaderTimeFromDateTime(currentTime);
|
||||||
|
return `/frame/${streamInfo.streamName}/source.png?timestamp=${wubloaderTime}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StreamTimeSettings
|
||||||
|
busStartTime={busStartTime}
|
||||||
|
streamVideoInfo={streamVideoInfo}
|
||||||
|
setStreamVideoInfo={setStreamVideoInfo}
|
||||||
|
showTimeRangeLink={false}
|
||||||
|
errorList={props.errorList}
|
||||||
|
setErrorList={props.setErrorList}
|
||||||
|
/>
|
||||||
|
<VideoPlayer
|
||||||
|
src={videoURL}
|
||||||
|
setPlayerTime={setPlayerTime}
|
||||||
|
mediaPlayer={mediaPlayer}
|
||||||
|
setMediaPlayer={setMediaPlayer}
|
||||||
|
/>
|
||||||
|
<div class={styles.videoLinks}>
|
||||||
|
<a href={downloadVideoURL()}>Download Video</a>
|
||||||
|
<a href={downloadFrameURL()}>Download Current Frame as Image</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|