Initial structure and clock
This is the start of replacing the old Thrimbletrimmer with a new SolidJS-based Thrimbletrimmer. It includes an implementation of the clock page for a basic implementation of signal-based page.thrimbletrimmer-solid
@ -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 |
@ -1,485 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>VST Video Editor</title>
|
<title>Thrimbletrimmer - Editor</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="styles/thrimbletrimmer.css" />
|
|
||||||
<link rel="stylesheet" href="styles/jcrop.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/edit.js"></script>
|
|
||||||
<script src="scripts/keyboard-shortcuts.js"></script>
|
|
||||||
<script src="scripts/jcrop.js"></script>
|
|
||||||
|
|
||||||
<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>
|
</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>
|
|
||||||
<li>
|
|
||||||
Left bracket ([): Set start point for active range (indicated by arrow) to active video
|
|
||||||
time
|
|
||||||
</li>
|
|
||||||
<li>Right bracket (]): Set end point for active range to active 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
|
|
||||||
active range is the last one
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
<form id="stream-time-settings">
|
|
||||||
<div>
|
|
||||||
<span class="field-label">Stream</span>
|
|
||||||
<span id="stream-time-setting-stream"></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="stream-time-setting-start" class="field-label">Start Time</label>
|
|
||||||
<input type="text" id="stream-time-setting-start" />
|
|
||||||
<button id="stream-time-setting-start-pad" type="button">Pad 1 minute</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="stream-time-setting-end" class="field-label">End Time</label>
|
|
||||||
<input type="text" id="stream-time-setting-end" />
|
|
||||||
<button id="stream-time-setting-end-pad" type="button">Pad 1 minute</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button type="submit" id="stream-time-settings-submit">Update Time Range</button>
|
|
||||||
</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 id="clip-bar"></div>
|
|
||||||
<div id="waveform-container">
|
|
||||||
<img id="waveform" alt="Waveform for the video" />
|
|
||||||
<div id="waveform-marker"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<input type="checkbox" id="enable-chapter-markers" />
|
|
||||||
<label for="enable-chapter-markers">Add chapter markers to video description</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div id="range-definitions">
|
|
||||||
<div>
|
|
||||||
<div class="range-definition-times">
|
|
||||||
<input type="text" class="range-definition-start" />
|
|
||||||
<img
|
|
||||||
src="images/pencil.png"
|
|
||||||
alt="Set range start point to the current video time"
|
|
||||||
title="Set range start point to the current video time"
|
|
||||||
class="range-definition-set-start click"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src="images/play_to.png"
|
|
||||||
alt="Play from start point"
|
|
||||||
title="Play from start point"
|
|
||||||
class="range-definition-play-start click"
|
|
||||||
/>
|
|
||||||
<div class="range-definition-between-time-gap"></div>
|
|
||||||
<input type="text" class="range-definition-end" />
|
|
||||||
<img
|
|
||||||
src="images/pencil.png"
|
|
||||||
alt="Set range end point to the current video time"
|
|
||||||
title="Set range end point to the current video time"
|
|
||||||
class="range-definition-set-end click"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src="images/play_to.png"
|
|
||||||
alt="Play from end point"
|
|
||||||
title="Play from range end point"
|
|
||||||
class="range-definition-play-end click"
|
|
||||||
/>
|
|
||||||
<div class="range-definition-icon-gap"></div>
|
|
||||||
<img
|
|
||||||
src="images/arrow.png"
|
|
||||||
alt="Range affected by keyboard shortcuts"
|
|
||||||
title="Range affected by keyboard shortcuts"
|
|
||||||
class="range-definition-current"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="range-definition-chapter-markers hidden">
|
|
||||||
<div>
|
|
||||||
<div class="range-definition-chapter-marker-start-field">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="range-definition-chapter-marker-start"
|
|
||||||
id="range-definition-chapter-marker-first-start"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<div class="range-definition-chapter-marker-edit-gap"></div>
|
|
||||||
<img
|
|
||||||
src="images/play_to.png"
|
|
||||||
alt="Play from chapter start time"
|
|
||||||
title="Play from chapter start time"
|
|
||||||
class="range-definition-chapter-marker-play-start click"
|
|
||||||
id="range-definition-chapter-marker-first-play-start"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="range-definition-chapter-marker-description"
|
|
||||||
id="range-definition-chapter-marker-first-description"
|
|
||||||
placeholder="Description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<img
|
|
||||||
src="images/plus.png"
|
|
||||||
alt="Add chapter marker"
|
|
||||||
title="Add chapter marker"
|
|
||||||
class="add-range-definition-chapter-marker click hidden"
|
|
||||||
tabindex="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<img
|
|
||||||
src="images/plus.png"
|
|
||||||
alt="Add range"
|
|
||||||
id="add-range-definition"
|
|
||||||
class="click"
|
|
||||||
tabindex="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="video-info">
|
|
||||||
<div id="video-info-editor-notes-container" class="hidden">
|
|
||||||
<div id="video-info-editor-notes-header">Notes to Editor:</div>
|
|
||||||
<div id="video-info-editor-notes"></div>
|
|
||||||
</div>
|
|
||||||
<label for="video-info-title">Title:</label>
|
|
||||||
<div id="video-info-title-full">
|
|
||||||
<span id="video-info-title-prefix"></span>
|
|
||||||
<input type="text" id="video-info-title" />
|
|
||||||
</div>
|
|
||||||
<label>Abbreviated title:</label>
|
|
||||||
<div id="video-info-title-abbreviated"></div>
|
|
||||||
<label for="video-info-description">Description:</label>
|
|
||||||
<textarea id="video-info-description"></textarea>
|
|
||||||
<label for="video-info-tags">Tags (comma-separated):</label>
|
|
||||||
<input type="text" id="video-info-tags" />
|
|
||||||
<label for="video-info-thumbnail-mode">Thumbnail:</label>
|
|
||||||
<div id="video-info-thumbnail">
|
|
||||||
<div>
|
|
||||||
<select id="video-info-thumbnail-mode">
|
|
||||||
<option value="NONE">No custom thumbnail</option>
|
|
||||||
<option value="BARE">Use video frame</option>
|
|
||||||
<option value="TEMPLATE" selected>Use video frame in image template</option>
|
|
||||||
<option value="ONEOFF">Use video frame with a custom one-off overlay</option>
|
|
||||||
<option value="CUSTOM">Use a custom thumbnail image</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="video-info-thumbnail-mode-options" id="video-info-thumbnail-template-options">
|
|
||||||
<select id="video-info-thumbnail-template"></select>
|
|
||||||
</div>
|
|
||||||
<div class="video-info-thumbnail-mode-options" id="video-info-thumbnail-time-options">
|
|
||||||
<input type="text" id="video-info-thumbnail-time" />
|
|
||||||
<img
|
|
||||||
src="images/pencil.png"
|
|
||||||
alt="Set video thumbnail frame to current video time"
|
|
||||||
class="click"
|
|
||||||
id="video-info-thumbnail-time-set"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src="images/play_to.png"
|
|
||||||
alt="Set video time to video thumbnail frame"
|
|
||||||
class="click"
|
|
||||||
id="video-info-thumbnail-time-play"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="video-info-thumbnail-mode-options" id="video-info-thumbnail-position-options">
|
|
||||||
<details>
|
|
||||||
<summary>Advanced Templating Options</summary>
|
|
||||||
Crop specifies the region of the video frame to capture. <br />
|
|
||||||
Location specifies the region within the template image where the cropped image will
|
|
||||||
be placed. <br />
|
|
||||||
Regions are given as pixel coordinates of the top-left and bottom-right corners.
|
|
||||||
<br />
|
|
||||||
Note that if the regions are different sizes, the image will be stretched. <br />
|
|
||||||
|
|
||||||
<button id="video-info-thumbnail-template-source-image-update">
|
|
||||||
Update Source Images
|
|
||||||
</button>
|
|
||||||
<button id="video-info-thumbnail-template-default-crop">
|
|
||||||
Reset Crop To Defaults
|
|
||||||
</button>
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<div class="video-info-thumbnail-advanced-crop-flex-wrapper">
|
|
||||||
<div class="video-info-thumbnail-advanced-crop-flex-item">
|
|
||||||
<img
|
|
||||||
id="video-info-thumbnail-template-video-source-image"
|
|
||||||
class="hidden"
|
|
||||||
alt="Thumbnail preview image"
|
|
||||||
height="360"
|
|
||||||
width="640"
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
Crop:
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="video-info-thumbnail-position"
|
|
||||||
id="video-info-thumbnail-crop-0"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="video-info-thumbnail-position"
|
|
||||||
id="video-info-thumbnail-crop-1"
|
|
||||||
/>
|
|
||||||
to
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="video-info-thumbnail-position"
|
|
||||||
id="video-info-thumbnail-crop-2"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="video-info-thumbnail-position"
|
|
||||||
id="video-info-thumbnail-crop-3"
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="video-info-thumbnail-advanced-crop-flex-item hidden"
|
|
||||||
id="video-info-thumbnail-aspect-ratio-controls"
|
|
||||||
>
|
|
||||||
<div class="video-info-thumbnail-advanced-crop-flex-column">
|
|
||||||
<div>Aspect Ratio</div>
|
|
||||||
<div>
|
|
||||||
<button id="video-info-thumbnail-aspect-ratio-match-right">
|
|
||||||
--Match->
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button id="video-info-thumbnail-aspect-ratio-match-left">
|
|
||||||
<-Match--
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" checked id="video-info-thumbnail-lock-aspect-ratio" />
|
|
||||||
Lock
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="video-info-thumbnail-advanced-crop-flex-item">
|
|
||||||
<img
|
|
||||||
id="video-info-thumbnail-template-overlay-image"
|
|
||||||
class="hidden"
|
|
||||||
alt="Thumbnail preview image"
|
|
||||||
height="360"
|
|
||||||
width="640"
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
Location:
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="video-info-thumbnail-position"
|
|
||||||
id="video-info-thumbnail-location-0"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="video-info-thumbnail-position"
|
|
||||||
id="video-info-thumbnail-location-1"
|
|
||||||
/>
|
|
||||||
to
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="video-info-thumbnail-position"
|
|
||||||
id="video-info-thumbnail-location-2"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="video-info-thumbnail-position"
|
|
||||||
id="video-info-thumbnail-location-3"
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="hidden video-info-thumbnail-mode-options"
|
|
||||||
id="video-info-thumbnail-custom-options"
|
|
||||||
>
|
|
||||||
<input type="file" id="video-info-thumbnail-custom" accept="image/png" />
|
|
||||||
</div>
|
|
||||||
<div class="video-info-thumbnail-mode-options" id="video-info-thumbnail-template-preview">
|
|
||||||
<button id="video-info-thumbnail-template-preview-generate">
|
|
||||||
Generate Thumbnail Preview
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<img
|
|
||||||
id="video-info-thumbnail-template-preview-image"
|
|
||||||
class="hidden"
|
|
||||||
alt="Thumbnail preview image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="submission">
|
|
||||||
<div id="submission-toolbar">
|
|
||||||
<button id="submit-button">Submit</button>
|
|
||||||
<button id="save-button">Save Draft</button>
|
|
||||||
<button id="submit-changes-button" class="hidden">Submit Changes</button>
|
|
||||||
<a id="advanced-submission" href="#">Advanced Submission Options</a>
|
|
||||||
</div>
|
|
||||||
<div id="advanced-submission-options" class="hidden">
|
|
||||||
<div>
|
|
||||||
<label for="advanced-submission-option-allow-holes">Allow holes</label>
|
|
||||||
<input type="checkbox" id="advanced-submission-option-allow-holes" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="advanced-submission-option-unlisted">Make unlisted</label>
|
|
||||||
<input type="checkbox" id="advanced-submission-option-unlisted" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="advanced-submission-option-upload-location">Upload location:</label>
|
|
||||||
<select id="advanced-submission-option-upload-location"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="advanced-submission-option-uploader-allow">Uploader allowlist:</label>
|
|
||||||
<input type="text" id="advanced-submission-option-uploader-allow" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="submission-response"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="download">
|
|
||||||
<label for="download-type-select">Download type:</label>
|
|
||||||
<select id="download-type-select">
|
|
||||||
<option value="smart" selected>Smart (experimental but preferred option)</option>
|
|
||||||
<option value="rough">Rough (raw content, pads start and end by a few seconds)</option>
|
|
||||||
<option value="fast">Fast (deprecated, use if smart is broken)</option>
|
|
||||||
<option value="mpegts">MPEG-TS (slow, consumes server resources)</option>
|
|
||||||
</select>
|
|
||||||
<a id="download-link">Download Video</a>
|
|
||||||
<a href="#" id="download-frame">Download Current Frame as Image</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="data-correction">
|
|
||||||
<div id="data-correction-toolbar">
|
|
||||||
<a id="manual-link-update" class="click">Manual Link Update</a>
|
|
||||||
|
|
|
||||||
<a id="cancel-video-upload" class="click">Cancel Upload</a>
|
|
||||||
|
|
|
||||||
<a id="reset-entire-video" class="click">Force Reset Row</a>
|
|
||||||
</div>
|
|
||||||
<div id="data-correction-manual-link" class="hidden">
|
|
||||||
<input type="text" id="data-correction-manual-link-entry" />
|
|
||||||
<label for="data-correction-manual-link-youtube"
|
|
||||||
>Is YouTube upload (add to playlists)?</label
|
|
||||||
>
|
|
||||||
<input type="checkbox" id="data-correction-manual-link-youtube" />
|
|
||||||
<button id="data-correction-manual-link-submit">Set Link</button>
|
|
||||||
<div id="data-correction-manual-link-response"></div>
|
|
||||||
</div>
|
|
||||||
<div id="data-correction-force-reset-confirm" class="hidden">
|
|
||||||
<p>Are you sure you want to reset this event?</p>
|
|
||||||
<p>
|
|
||||||
This will set the row back to Unedited and forget about any video that may already
|
|
||||||
exist.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
This is intended as a last-ditch effort to clear a malfunctioning cutter, or if a video
|
|
||||||
needs to be reedited and replaced.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong
|
|
||||||
>It is your responsibility to deal with any video that may have already been
|
|
||||||
uploaded.</strong
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<button id="data-correction-force-reset-yes">Yes, reset it!</button>
|
|
||||||
<button id="data-correction-force-reset-no">Oh, never mind!</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div id="data-correction-cancel-response"></div>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<div id="chat-replay"></div>
|
<script src="/src/edit.tsx" type="module"></script>
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
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,24 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"solid-js": "1.9.3"
|
||||||
|
}
|
||||||
|
}
|
@ -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,7 @@
|
|||||||
|
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,7 @@
|
|||||||
|
import { render } from "solid-js/web";
|
||||||
|
|
||||||
|
import Restreamer from "./restreamer/Restreamer";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
|
render(() => <Restreamer />, root!);
|
@ -0,0 +1,7 @@
|
|||||||
|
import { Component } from "solid-js";
|
||||||
|
|
||||||
|
const Restreamer: Component = () => {
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Restreamer;
|
@ -0,0 +1,7 @@
|
|||||||
|
import { render } from "solid-js/web";
|
||||||
|
|
||||||
|
import ThumbnailManager from "./thumbnails/ThumbnailManager";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
|
render(() => <ThumbnailManager />, root!);
|
@ -0,0 +1,7 @@
|
|||||||
|
import { Component } from "solid-js";
|
||||||
|
|
||||||
|
const ThumbnailManager: Component = () => {
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThumbnailManager;
|
@ -0,0 +1,54 @@
|
|||||||
|
import { Component, createSignal, onCleanup, onMount } from "solid-js";
|
||||||
|
import { DateTime, Interval } from "../external/luxon.min";
|
||||||
|
|
||||||
|
const Clock: Component = () => {
|
||||||
|
const [delay, setDelay] = createSignal<number>(10);
|
||||||
|
const [time, setTime] = createSignal<DateTime>(DateTime.utc());
|
||||||
|
const [busStartTime, setBusStartTime] = createSignal<DateTime | null>(null);
|
||||||
|
|
||||||
|
const timer = setInterval(() => setTime(DateTime.utc()), 250);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const dataResponse = await fetch("/thrimshim/defaults");
|
||||||
|
const data = await dataResponse.json();
|
||||||
|
setBusStartTime(DateTime.fromISO(data.bustime_start));
|
||||||
|
});
|
||||||
|
|
||||||
|
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,12 @@
|
|||||||
|
import { Component } from "solid-js";
|
||||||
|
import Clock from "./Clock";
|
||||||
|
|
||||||
|
const Utilities: Component = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Clock />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Utilities;
|
@ -0,0 +1,7 @@
|
|||||||
|
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,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Thrimbletrimmer - Utilities</title>
|
||||||
|
</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": ["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,23 @@
|
|||||||
|
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",
|
||||||
|
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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|