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.
ElementalAlchemist 3 weeks ago
parent 2aadf79bfb
commit 05a53924f6

@ -66,7 +66,9 @@
// Thrimbletrimmer (and probably not useful otherwise) to enable live updates // Thrimbletrimmer (and probably not useful otherwise) to enable live updates
// to Thrimbletrimmer without restarting/rebuilding Wubloader. // to Thrimbletrimmer without restarting/rebuilding Wubloader.
// If you wish to use this, set this to the path containing the Thrimbletrimmer // If you wish to use this, set this to the path containing the Thrimbletrimmer
// web (HTML, CSS, JavaScript) files to serve (e.g. "/path/to/wubloader/thrimbletrimmer/"). // web (HTML, CSS, JavaScript) files to serve (e.g. "/path/to/wubloader/thrimbletrimmer/dist/").
// This directory should be the build target when building Thrimbletrimmer manually; by default,
// this is the /dist/ subdirectory.
thrimbletrimmer_web_dev_path:: null, thrimbletrimmer_web_dev_path:: null,
// The host's port to expose each service on. // The host's port to expose each service on.

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

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();
}

Binary file not shown.

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-&gt;
</button>
</div>
<div>
<button id="video-info-thumbnail-aspect-ratio-match-left">
&lt;-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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

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));
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -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;
}
});

File diff suppressed because one or more lines are too long

@ -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;

File diff suppressed because one or more lines are too long

@ -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)}
/>
&#32;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)),
},
},
},
});
Loading…
Cancel
Save