Compare commits

...

23 Commits

Author SHA1 Message Date
ElementalAlchemist 4298937535 Fix losing milliseconds sometimes 2 weeks ago
ElementalAlchemist 5143d15f68 Fix time ago conversion occurring in the wrong direction 2 weeks ago
ElementalAlchemist 0fd6a09b1c Store player settings 2 weeks ago
ElementalAlchemist 2095880d10 Switch to more native video controls 2 weeks ago
ElementalAlchemist bb85eb494d Remove the default video controls 2 weeks ago
ElementalAlchemist aa649ac4ac Implement basic video controls
These will be expanded or replaced with vidstack's controls in the future, but this at least gives us most of the basic requirements.
2 weeks ago
ElementalAlchemist aeebf0b7ad Bound max video player size 2 weeks ago
ElementalAlchemist 9a8be2f875 Make the video load bar work properly 2 weeks ago
ElementalAlchemist 0aed412ccb Add vidstack video player 2 weeks ago
ElementalAlchemist 89ff17564e Basic loading of videos 2 weeks ago
ElementalAlchemist 6608555a8b Add cancel/reset buttons for thumbnail edit 2 weeks ago
ElementalAlchemist 87e8333d05 Add video load time fields to restreamer 2 weeks ago
ElementalAlchemist 4517ec1e68 Add errors and keyboard shortcuts to restreamer 2 weeks ago
ElementalAlchemist 5ab145b031 Add components for keyboard shortcuts 2 weeks ago
ElementalAlchemist 2bb8ff6245 Add Hls.js as a dependency 2 weeks ago
ElementalAlchemist 7de2f807b1 Move luxon dependency to npm 2 weeks ago
ElementalAlchemist c3448542c4 Add template form 2 weeks ago
ElementalAlchemist 6b381428ed Enable viewing and editing thumbnail templates 2 weeks ago
ElementalAlchemist ae808eedde Fix pressing "enter" in time converter reloading the page 2 weeks ago
ElementalAlchemist 369b70bb19 Use UTC as the default time zone when converting if no other time zone is specified 2 weeks ago
ElementalAlchemist 1c842d9d16 Add the time converter utility 2 weeks ago
ElementalAlchemist ea438c73db Update Dockerfile to build Thrimbletrimmer when building images 2 weeks ago
ElementalAlchemist 05a53924f6 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.
2 weeks ago

@ -66,7 +66,9 @@
// Thrimbletrimmer (and probably not useful otherwise) to enable live updates
// to Thrimbletrimmer without restarting/rebuilding Wubloader.
// 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,
// The host's port to expose each service on.

@ -4,10 +4,16 @@ ADD buscribe-web /assets
WORKDIR /assets
RUN lessc style.less > style.css
FROM node:23.1-alpine AS thrim
ADD thrimbletrimmer /assets
WORKDIR /assets
RUN npm install
RUN npm run build
# nginx container contains config that exposes all the various services metrics
FROM nginx:latest
ADD nginx/generate-config /
COPY --from=less /assets /etc/nginx/html/buscribe
COPY thrimbletrimmer /etc/nginx/html/thrimbletrimmer
COPY --from=thrim /assets/dist /etc/nginx/html/thrimbletrimmer
LABEL org.opencontainers.image.source https://github.com/dbvideostriketeam/wubloader
ENTRYPOINT ["/bin/sh", "-c", "/generate-config && nginx -g \"daemon off;\""]

@ -0,0 +1,3 @@
node_modules
dist
package-lock.json

@ -1,4 +1 @@
scripts/hls.min.js
scripts/luxon.min.js
scripts/jcrop.js
styles/jcrop.css
src/external

@ -1,56 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Stream Time</title>
<style type="text/css">
#clock {
margin-bottom: 3px;
}
</style>
</head>
<body>
<div id="clock"></div>
<div><input type="number" id="delay" value="10" min="0" /> seconds of delay</div>
<script type="text/javascript">
let busStartTime = null;
function updateClock() {
let delay = parseInt(document.getElementById("delay").value);
if (isNaN(delay)) {
delay = 0;
}
let time = (new Date() - busStartTime) / 1000 - delay;
let sign = "";
if (time < 0) {
time = -time;
sign = "-";
}
let hours = Math.trunc(time / 3600).toString();
let mins = Math.trunc((time % 3600) / 60).toString();
let secs = Math.trunc(time % 60).toString();
if (mins.length < 2) {
mins = "0" + mins;
}
if (secs.length < 2) {
secs = "0" + secs;
}
let formatted = sign + hours + ":" + mins + ":" + secs;
document.getElementById("clock").innerText = formatted;
}
async function initialize() {
const dataResponse = await fetch("/thrimshim/defaults");
const data = await dataResponse.json();
busStartTime = new Date(data.bustime_start);
setInterval(updateClock, 1000);
}
initialize();
</script>
</body>
</html>

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,20 +1,8 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>VST Video 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>
<title>Thrimbletrimmer - Editor</title>
<meta
name="google-signin-client_id"
content="345276493482-r84m2giavk10glnmqna0lbq8e1hdaus0.apps.googleusercontent.com"
@ -22,464 +10,8 @@
<script src="https://apis.google.com/js/platform.js?onload=onGLoad" async defer></script>
</head>
<body>
<div id="errors"></div>
<div id="page-container">
<details id="editor-help">
<summary>Keyboard Shortcuts</summary>
<ul>
<li>Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)</li>
<li>K or Space: Toggle pause</li>
<li>M: Toggle mute</li>
<li>J: Back 10 seconds</li>
<li>L: Forward 10 seconds</li>
<li>Left arrow: Back 5 seconds</li>
<li>Right arrow: Forward 5 seconds</li>
<li>Shift+J: Back 1 second</li>
<li>Shift+L: Forward 1 second</li>
<li>Comma (,): Back 1 frame</li>
<li>Period (.): Forward 1 frame</li>
<li>Equals (=): Increase playback speed one step</li>
<li>Hyphen (-): Decrease playback speed one step</li>
<li>Shift+=: 2x or maximum playback speed</li>
<li>Shift+-: Minimum playback speed</li>
<li>Backspace: Reset playback speed to 1x</li>
<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="root"></div>
<div id="chat-replay"></div>
</div>
<script src="/src/edit.tsx" type="module"></script>
</body>
</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>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>VST Restreamer</title>
<link rel="stylesheet" href="styles/thrimbletrimmer.css" />
<script src="scripts/hls.min.js"></script>
<script src="scripts/luxon.min.js"></script>
<script src="scripts/common-worker.js"></script>
<script src="scripts/common.js"></script>
<script src="scripts/stream.js"></script>
<script src="scripts/keyboard-shortcuts.js"></script>
<title>Thrimbletrimmer - Restreamer</title>
</head>
<body>
<div id="errors"></div>
<div id="page-container">
<details id="editor-help">
<summary>Keyboard Shortcuts</summary>
<ul>
<li>Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)</li>
<li>K or Space: Toggle pause</li>
<li>M: Toggle mute</li>
<li>J: Back 10 seconds</li>
<li>L: Forward 10 seconds</li>
<li>Left arrow: Back 5 seconds</li>
<li>Right arrow: Forward 5 seconds</li>
<li>Shift+J: Back 1 second</li>
<li>Shift+L: Forward 1 second</li>
<li>Comma (,): Back 1 frame</li>
<li>Period (.): Forward 1 frame</li>
<li>Equals (=): Increase playback speed one step</li>
<li>Hyphen (-): Decrease playback speed one step</li>
<li>Shift+=: 2x or maximum playback speed</li>
<li>Shift+-: Minimum playback speed</li>
<li>Backspace: Reset playback speed to 1x</li>
</ul>
</details>
<form id="stream-time-settings">
<div>
<label for="stream-time-setting-stream" class="field-label">Stream</label>
<input type="text" id="stream-time-setting-stream" />
</div>
<div>
<label for="stream-time-setting-start" class="field-label">Start Time</label>
<input type="text" id="stream-time-setting-start" value="0:10:00" />
</div>
<div>
<label for="stream-time-setting-end" class="field-label">End Time</label>
<input type="text" id="stream-time-setting-end" />
</div>
<div>
<div id="stream-time-frame-of-reference">
<input
type="radio"
name="time-frame-of-reference"
id="stream-time-frame-of-reference-utc"
value="1"
/>
<label for="stream-time-frame-of-reference-utc">UTC</label>
<input
type="radio"
name="time-frame-of-reference"
id="stream-time-frame-of-reference-bus"
value="2"
/>
<label for="stream-time-frame-of-reference-bus">Bus Time</label>
<input
type="radio"
name="time-frame-of-reference"
id="stream-time-frame-of-reference-ago"
value="3"
checked
/>
<label for="stream-time-frame-of-reference-ago">Time Ago</label>
</div>
</div>
<div>
<button id="stream-time-settings-submit" type="submit">Update Time Range</button>
</div>
<div>
<a href="" id="stream-time-link">Link to this time range</a>
</div>
</form>
<video id="video" preload="auto"></video>
<div id="video-controls">
<div id="video-controls-bar">
<div>
<img
id="video-controls-play-pause"
src="images/video-controls/play.png"
class="click"
/>
</div>
<div id="video-controls-time">
<span id="video-controls-current-time"></span>
/
<span id="video-controls-duration"></span>
</div>
<div id="video-controls-spacer"></div>
<div id="video-controls-volume">
<img
id="video-controls-volume-mute"
src="images/video-controls/volume.png"
class="click"
/>
<progress id="video-controls-volume-level" value="0.5" class="click"></progress>
</div>
<div>
<select id="video-controls-playback-speed"></select>
</div>
<div>
<select id="video-controls-quality"></select>
</div>
<div>
<img
id="video-controls-fullscreen"
src="images/video-controls/fullscreen.png"
class="click"
/>
</div>
</div>
<progress id="video-controls-playback-position" value="0" class="click"></progress>
</div>
<div>
<a href="#" id="download">Download Video</a>
<a href="#" id="download-frame">Download Current Frame as Image</a>
<a href="#" id="time-converter-link">Convert Times</a>
</div>
<form id="time-converter" class="hidden">
<h2>Time Converter</h2>
<div id="time-converter-time-container">
<input class="time-converter-time" type="text" placeholder="Time to convert" />
</div>
<img
src="images/plus.png"
id="time-converter-add-time"
tooltip="Add time conversion field"
class="click"
tabindex="0"
/>
<div>
From:
<input name="time-converter-from" id="time-converter-from-utc" type="radio" value="1" />
<label for="time-converter-from-utc">UTC</label>
<input name="time-converter-from" id="time-converter-from-bus" type="radio" value="2" />
<label for="time-converter-from-bus">Bus Time</label>
<input name="time-converter-from" id="time-converter-from-ago" type="radio" value="3" />
<label for="time-converter-from-ago">Time Ago</label>
</div>
<div>
To:
<input name="time-converter-to" id="time-converter-to-utc" type="radio" value="1" />
<label for="time-converter-to-utc">UTC</label>
<input name="time-converter-to" id="time-converter-to-bus" type="radio" value="2" />
<label for="time-converter-to-bus">Bus Time</label>
<input name="time-converter-to" id="time-converter-to-ago" type="radio" value="3" />
<label for="time-converter-to-ago">Time Ago</label>
</div>
<button type="submit">Convert Times</button>
</form>
<div id="root"></div>
<div id="chat-replay"></div>
</div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

@ -0,0 +1,29 @@
{
"name": "thrimbletrimmer",
"version": "4.0.0",
"description": "Video editor frontend for Wubloader",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"license": "MIT",
"devDependencies": {
"sass": "1.80.6",
"solid-devtools": "0.30.1",
"typescript": "5.6.3",
"url": "0.11.4",
"vite": "5.4.10",
"vite-plugin-solid": "2.10.2",
"@types/luxon": "3.4.2"
},
"dependencies": {
"hls.js": "1.5.17",
"luxon": "3.4.4",
"media-icons": "1.1.5",
"solid-js": "1.9.3",
"vidstack": "1.12.12"
}
}

@ -1,49 +0,0 @@
self.importScripts("luxon.min.js", "common-worker.js");
var DateTime = luxon.DateTime;
luxon.Settings.defaultZone = "utc";
self.onmessage = async (event) => {
const chatLoadData = event.data;
const segmentMetadata = chatLoadData.segmentMetadata;
for (const segmentData of segmentMetadata) {
segmentData.rawStart = DateTime.fromMillis(segmentData.rawStart);
segmentData.rawEnd = DateTime.fromMillis(segmentData.rawEnd);
}
const fetchURL = `/${chatLoadData.stream}/chat.json?start=${chatLoadData.start}&end=${chatLoadData.end}`;
const chatResponse = await fetch(fetchURL);
if (!chatResponse.ok) {
return;
}
const chatRawData = await chatResponse.json();
const chatData = [];
for (const chatLine of chatRawData) {
if (
chatLine.command !== "PRIVMSG" &&
chatLine.command !== "CLEARMSG" &&
chatLine.command !== "CLEARCHAT" &&
chatLine.command !== "USERNOTICE"
) {
continue;
}
const when = DateTime.fromSeconds(chatLine.time);
const displayWhen = videoHumanTimeFromDateTimeWithFragments(segmentMetadata, when);
// Here, we just push each line successively into the list. This assumes data is provided to us in chronological order.
chatData.push({ message: chatLine, when: when.toMillis(), displayWhen: displayWhen });
}
self.postMessage(chatData);
};
function videoHumanTimeFromDateTimeWithFragments(fragmentMetadata, dateTime) {
for (const segmentData of fragmentMetadata) {
if (dateTime >= segmentData.rawStart && dateTime <= segmentData.rawEnd) {
const playerTime =
segmentData.playerStart + dateTime.diff(segmentData.rawStart).as("seconds");
return videoHumanTimeFromVideoPlayerTime(playerTime);
}
}
return null;
}

@ -1,21 +0,0 @@
function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) {
const hours = Math.floor(videoPlayerTime / 3600);
let minutes = Math.floor((videoPlayerTime % 3600) / 60);
let seconds = Math.floor(videoPlayerTime % 60);
let milliseconds = Math.floor((videoPlayerTime * 1000) % 1000);
while (minutes.toString().length < 2) {
minutes = `0${minutes}`;
}
while (seconds.toString().length < 2) {
seconds = `0${seconds}`;
}
while (milliseconds.toString().length < 3) {
milliseconds = `0${milliseconds}`;
}
if (hours > 0) {
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
}
return `${minutes}:${seconds}.${milliseconds}`;
}

@ -1,736 +0,0 @@
var DateTime = luxon.DateTime;
var Interval = luxon.Interval;
luxon.Settings.defaultZone = "utc";
var globalBusStartTime = DateTime.fromISO("1970-01-01T00:00:00");
var globalStreamName = "";
var globalStartTimeString = "";
var globalEndTimeString = "";
var globalPlayer = null;
var globalSetUpControls = false;
var globalSeekTimer = null;
var globalChatData = [];
var globalLoadChatWorker = null;
Hls.DefaultConfig.maxBufferHole = 600;
const VIDEO_FRAMES_PER_SECOND = 30;
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4, 8];
function commonPageSetup() {
if (!Hls.isSupported()) {
addError(
"Your browser doesn't support MediaSource extensions. Video playback and editing won't work.",
);
}
globalLoadChatWorker = new Worker("scripts/chat-load.js");
}
function addError(errorText) {
const errorElement = document.createElement("div");
errorElement.innerText = errorText;
const dismissElement = document.createElement("a");
dismissElement.classList.add("error-dismiss");
dismissElement.innerText = "[X]";
errorElement.appendChild(dismissElement);
dismissElement.addEventListener("click", (event) => {
const errorHost = document.getElementById("errors");
errorHost.removeChild(errorElement);
});
const errorHost = document.getElementById("errors");
errorHost.appendChild(errorElement);
}
async function loadVideoPlayer(playlistURL) {
let rangedPlaylistURL = assembleVideoPlaylistURL(playlistURL);
const videoElement = document.getElementById("video");
videoElement.addEventListener("loadedmetadata", (_event) => {
setUpVideoControls();
sendChatLogLoadData();
});
videoElement.addEventListener("loadeddata", (_event) => {
const qualitySelector = document.getElementById("video-controls-quality");
globalPlayer.currentLevel = +qualitySelector.value;
});
globalPlayer = new Hls();
globalPlayer.attachMedia(video);
return new Promise((resolve, _reject) => {
globalPlayer.on(Hls.Events.MEDIA_ATTACHED, () => {
const startTime = getStartTime();
const endTime = getEndTime();
if (endTime && endTime.diff(startTime).milliseconds < 0) {
addError(
"End time is before the start time. This will prevent video loading and cause other problems.",
);
}
globalPlayer.loadSource(rangedPlaylistURL);
globalPlayer.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
if (data.reason === "no level found in manifest") {
addError(
"There is no video data between the specified start and end times. Change the times so that there is video content to play.",
);
} else {
console.log("A fatal network error occurred; retrying", data);
globalPlayer.startLoad();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log("A fatal media error occurred; retrying", data);
globalPlayer.recoverMediaError();
break;
default:
console.log("A fatal error occurred; resetting video player", data);
addError(
"Some sort of video player error occurred. Thrimbletrimmer is resetting the video player.",
);
resetVideoPlayer();
}
} else {
console.log("A non-fatal video player error occurred; HLS.js will retry", data);
}
});
resolve();
});
});
}
async function loadVideoPlayerFromDefaultPlaylist() {
const playlistURL = `/playlist/${globalStreamName}.m3u8`;
await loadVideoPlayer(playlistURL);
}
function resetVideoPlayer() {
updateSegmentPlaylist();
}
function updateSegmentPlaylist() {
const videoElement = document.getElementById("video");
const currentPlaybackRate = videoElement.playbackRate;
globalPlayer.destroy();
loadVideoPlayerFromDefaultPlaylist();
// The playback rate isn't maintained when destroying and reattaching hls.js
videoElement.playbackRate = currentPlaybackRate;
}
function setUpVideoControls() {
// Setting this up so it's removed from the event doesn't work; loadedmetadata fires twice anyway.
// We still need to prevent double-setup, so here we are.
if (globalSetUpControls) {
return;
}
globalSetUpControls = true;
const videoElement = document.getElementById("video");
const playPauseButton = document.getElementById("video-controls-play-pause");
if (videoElement.paused) {
playPauseButton.src = "images/video-controls/play.png";
} else {
playPauseButton.src = "images/video-controls/pause.png";
}
const togglePlayState = (_event) => {
if (videoElement.paused) {
videoElement.play();
} else {
videoElement.pause();
}
};
playPauseButton.addEventListener("click", togglePlayState);
videoElement.addEventListener("click", (event) => {
if (!videoElement.controls) {
togglePlayState(event);
}
});
videoElement.addEventListener("play", (_event) => {
playPauseButton.src = "images/video-controls/pause.png";
});
videoElement.addEventListener("pause", (_event) => {
playPauseButton.src = "images/video-controls/play.png";
});
const currentTime = document.getElementById("video-controls-current-time");
currentTime.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
videoElement.addEventListener("timeupdate", (_event) => {
currentTime.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
});
const duration = document.getElementById("video-controls-duration");
duration.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.duration);
videoElement.addEventListener("durationchange", (_event) => {
duration.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.duration);
});
const volumeMuted = document.getElementById("video-controls-volume-mute");
if (videoElement.muted) {
volumeMuted.src = "images/video-controls/volume-mute.png";
} else {
volumeMuted.src = "images/video-controls/volume.png";
}
const volumeLevel = document.getElementById("video-controls-volume-level");
const defaultVolume = +(localStorage.getItem("volume") ?? 0.5);
if (isNaN(defaultVolume)) {
defaultVolume = 0.5;
} else if (defaultVolume < 0) {
defaultVolume = 0;
} else if (defaultVolume > 1) {
defaultVolume = 1;
}
videoElement.volume = defaultVolume;
volumeLevel.value = videoElement.volume;
volumeMuted.addEventListener("click", (_event) => {
videoElement.muted = !videoElement.muted;
});
volumeLevel.addEventListener("click", (event) => {
videoElement.volume = event.offsetX / event.target.offsetWidth;
videoElement.muted = false;
});
videoElement.addEventListener("volumechange", (_event) => {
if (videoElement.muted) {
volumeMuted.src = "images/video-controls/volume-mute.png";
} else {
volumeMuted.src = "images/video-controls/volume.png";
}
volumeLevel.value = videoElement.volume;
localStorage.setItem("volume", videoElement.volume);
});
const playbackSpeed = document.getElementById("video-controls-playback-speed");
for (const speed of PLAYBACK_RATES) {
const speedOption = document.createElement("option");
speedOption.value = speed;
speedOption.innerText = `${speed}x`;
if (speed === 1) {
speedOption.selected = true;
}
playbackSpeed.appendChild(speedOption);
}
playbackSpeed.addEventListener("change", (_event) => {
const speed = +playbackSpeed.value;
videoElement.playbackRate = speed;
});
const quality = document.getElementById("video-controls-quality");
const defaultQuality = localStorage.getItem("quality");
for (const [qualityIndex, qualityLevel] of globalPlayer.levels.entries()) {
const qualityOption = document.createElement("option");
qualityOption.value = qualityIndex;
qualityOption.innerText = qualityLevel.name;
if (qualityLevel.name === defaultQuality) {
qualityOption.selected = true;
}
quality.appendChild(qualityOption);
}
localStorage.setItem("quality", quality.options[quality.options.selectedIndex].innerText);
quality.addEventListener("change", (_event) => {
globalPlayer.currentLevel = +quality.value;
localStorage.setItem("quality", quality.options[quality.options.selectedIndex].innerText);
});
const fullscreen = document.getElementById("video-controls-fullscreen");
fullscreen.addEventListener("click", (_event) => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoElement.requestFullscreen();
}
});
videoElement.addEventListener("fullscreenchange", (_event) => {
if (document.fullscreenElement) {
videoElement.controls = true;
} else {
videoElement.controls = false;
}
});
const playbackPosition = document.getElementById("video-controls-playback-position");
playbackPosition.max = videoElement.duration;
playbackPosition.value = videoElement.currentTime;
videoElement.addEventListener("durationchange", (_event) => {
playbackPosition.max = videoElement.duration;
});
videoElement.addEventListener("timeupdate", (_event) => {
playbackPosition.value = videoElement.currentTime;
});
playbackPosition.addEventListener("click", (event) => {
const newPosition = (event.offsetX / event.target.offsetWidth) * videoElement.duration;
videoElement.currentTime = newPosition;
playbackPosition.value = newPosition;
});
/* Sometimes a mysterious issue occurs loading segments of the video when seeking.
* When this happens, twiddling the qualities tends to fix it. Here, we attempt to
* detect this situation and fix it automatically.
*/
videoElement.addEventListener("seeking", (_event) => {
// If we don't get a "seeked" event soon after the "seeking" event, we assume there's
// a loading error.
// To handle this, we set up a timed handler to pick this up.
if (globalSeekTimer !== null) {
clearTimeout(globalSeekTimer);
globalSeekTimer = null;
}
globalSeekTimer = setTimeout(() => {
const currentLevel = globalPlayer.currentLevel;
globalPlayer.currentLevel = -1;
globalPlayer.currentLevel = currentLevel;
}, 500);
});
videoElement.addEventListener("seeked", (_event) => {
// Since we got the seek, cancel the timed twiddling of qualities
if (globalSeekTimer !== null) {
clearTimeout(globalSeekTimer);
globalSeekTimer = null;
}
});
}
function dateTimeMathObjectFromBusTime(busTime) {
// We need to handle inputs like "-0:10:15" in a way that consistently makes the time negative.
// Since we can't assign the negative sign to any particular part, we'll check for the whole thing here.
let direction = 1;
if (busTime.startsWith("-")) {
busTime = busTime.slice(1);
direction = -1;
}
const parts = busTime.split(":", 3);
const hours = parseInt(parts[0]) * direction;
const minutes = (parts[1] || 0) * direction;
const seconds = (parts[2] || 0) * direction;
return { hours: hours, minutes: minutes, seconds: seconds };
}
function dateTimeFromBusTime(busTime) {
return globalBusStartTime.plus(dateTimeMathObjectFromBusTime(busTime));
}
function busTimeFromDateTime(dateTime) {
const diff = dateTime.diff(globalBusStartTime);
return formatIntervalForDisplay(diff);
}
function formatIntervalForDisplay(interval) {
if (interval.milliseconds < 0) {
const negativeInterval = interval.negate();
return `-${negativeInterval.toFormat("hh:mm:ss.SSS")}`;
}
return interval.toFormat("hh:mm:ss.SSS");
}
function dateTimeFromWubloaderTime(wubloaderTime) {
return DateTime.fromISO(wubloaderTime);
}
function wubloaderTimeFromDateTime(dateTime) {
if (!dateTime) {
return null;
}
// Not using ISO here because Luxon doesn't give us a quick way to print an ISO8601 string with no offset.
return dateTime.toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
}
function busTimeFromWubloaderTime(wubloaderTime) {
if (wubloaderTime === "") {
return "";
}
const dt = dateTimeFromWubloaderTime(wubloaderTime);
return busTimeFromDateTime(dt);
}
function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) {
const hours = Math.floor(videoPlayerTime / 3600);
let minutes = Math.floor((videoPlayerTime % 3600) / 60);
let seconds = Math.floor(videoPlayerTime % 60);
let milliseconds = Math.floor((videoPlayerTime * 1000) % 1000);
while (minutes.toString().length < 2) {
minutes = `0${minutes}`;
}
while (seconds.toString().length < 2) {
seconds = `0${seconds}`;
}
while (milliseconds.toString().length < 3) {
milliseconds = `0${milliseconds}`;
}
if (hours > 0) {
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
}
return `${minutes}:${seconds}.${milliseconds}`;
}
function videoPlayerTimeFromVideoHumanTime(videoHumanTime) {
let timeParts = videoHumanTime.split(":", 3);
let hours;
let minutes;
let seconds;
if (timeParts.length < 2) {
hours = 0;
minutes = 0;
seconds = +timeParts[0];
} else if (timeParts.length < 3) {
hours = 0;
minutes = parseInt(timeParts[0]);
seconds = +timeParts[1];
} else {
hours = parseInt(timeParts[0]);
minutes = parseInt(timeParts[1]);
seconds = +timeParts[2];
}
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
function dateTimeFromVideoPlayerTime(videoPlayerTime) {
const segmentList = getSegmentList();
let segmentStartTime;
let segmentStartISOTime;
for (const segment of segmentList) {
const segmentEndTime = segment.start + segment.duration;
if (videoPlayerTime >= segment.start && videoPlayerTime < segmentEndTime) {
segmentStartTime = segment.start;
segmentStartISOTime = segment.rawProgramDateTime;
break;
}
}
if (segmentStartISOTime === undefined) {
return null;
}
const wubloaderDateTime = DateTime.fromISO(segmentStartISOTime);
const offset = videoPlayerTime - segmentStartTime;
return wubloaderDateTime.plus({ seconds: offset });
}
function videoPlayerTimeFromDateTime(dateTime) {
const segmentTimes = getSegmentTimes();
for (const segmentData of segmentTimes) {
const segmentStart = segmentData.rawStart;
const segmentEnd = segmentData.rawEnd;
if (dateTime >= segmentStart && dateTime <= segmentEnd) {
return segmentData.playerStart + dateTime.diff(segmentStart).as("seconds");
}
}
return null;
}
function videoHumanTimeFromDateTime(dateTime) {
const videoPlayerTime = videoPlayerTimeFromDateTime(dateTime);
if (videoPlayerTime === null) {
return null;
}
return videoHumanTimeFromVideoPlayerTime(videoPlayerTime);
}
function assembleVideoPlaylistURL(basePlaylistURL) {
let playlistURL = basePlaylistURL;
const query = startAndEndTimeQuery();
if (query.toString() !== "") {
playlistURL += "?" + query.toString();
}
return playlistURL;
}
function startAndEndTimeQuery() {
const startTime = getStartTime();
const endTime = getEndTime();
const query = new URLSearchParams();
if (startTime) {
query.append("start", wubloaderTimeFromDateTime(startTime));
}
if (endTime) {
query.append("end", wubloaderTimeFromDateTime(endTime));
}
return query;
}
function getSegmentList() {
return globalPlayer.latencyController.levelDetails.fragments;
}
function hasSegmentList() {
if (
globalPlayer &&
globalPlayer.latencyController &&
globalPlayer.latencyController.levelDetails &&
globalPlayer.latencyController.levelDetails.fragments
) {
return true;
}
return false;
}
function getSegmentTimes() {
const segmentList = getSegmentList();
const segmentTimes = [];
for (const segment of segmentList) {
const segmentStart = DateTime.fromISO(segment.rawProgramDateTime);
const segmentEnd = segmentStart.plus({ seconds: segment.duration });
segmentTimes.push({ rawStart: segmentStart, rawEnd: segmentEnd, playerStart: segment.start });
}
return segmentTimes;
}
function downloadFrame() {
const videoElement = document.getElementById("video");
const dateTime = dateTimeFromVideoPlayerTime(videoElement.currentTime);
const url = `/frame/${globalStreamName}/source.png?timestamp=${wubloaderTimeFromDateTime(
dateTime,
)}`;
// Avoid : as it causes problems on Windows
const filename = `${dateTime.toFormat("yyyy-LL-dd'T'HH-mm-ss.SSS")}.png`;
triggerDownload(url, filename);
}
function triggerDownload(url, filename) {
// URL must be same-origin.
const link = document.createElement("a");
link.setAttribute("download", filename);
link.href = url;
link.setAttribute("target", "_blank");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function sendChatLogLoadData() {
let startTime = getStartTime();
let endTime = getEndTime();
if (!startTime || !endTime) {
return;
}
startTime = wubloaderTimeFromDateTime(startTime);
endTime = wubloaderTimeFromDateTime(endTime);
const segmentMetadata = getSegmentTimes();
for (const segmentData of segmentMetadata) {
segmentData.rawStart = segmentData.rawStart.toMillis();
segmentData.rawEnd = segmentData.rawEnd.toMillis();
}
const message = {
stream: globalStreamName,
start: startTime,
end: endTime,
segmentMetadata: segmentMetadata,
};
globalLoadChatWorker.postMessage(message);
}
function updateChatDataFromWorkerResponse(chatData) {
for (const chatLine of chatData) {
chatLine.when = DateTime.fromMillis(chatLine.when);
}
globalChatData = chatData;
}
function renderChatMessage(chatMessageData) {
const chatMessage = chatMessageData.message;
if (chatMessage.command !== "PRIVMSG" || chatMessage.params[0] !== `#${globalStreamName}`) {
return null;
}
const sendTimeElement = document.createElement("div");
sendTimeElement.classList.add("chat-replay-message-time");
sendTimeElement.innerText = chatMessageData.displayWhen;
const senderNameElement = createMessageSenderElement(chatMessageData);
const messageTextElement = document.createElement("div");
messageTextElement.classList.add("chat-replay-message-text");
if (chatMessage.tags.hasOwnProperty("reply-parent-msg-id")) {
const replyParentID = chatMessage.tags["reply-parent-msg-id"];
const replyParentSender = chatMessage.tags["reply-parent-display-name"];
let replyParentMessageText = chatMessage.tags["reply-parent-msg-body"];
const replyContainer = document.createElement("div");
const replyTextContainer = document.createElement("a");
if (replyParentMessageText.startsWith("\u0001ACTION")) {
replyContainer.classList.add("chat-replay-message-text-action");
const substringEnd = replyParentMessageText.endsWith("\u0001")
? replyParentMessageText.length - 1
: replyParentMessageText;
replyParentMessageText = replyParentMessageText.substring(7, substringEnd);
}
replyTextContainer.href = `#chat-replay-message-${replyParentID}`;
replyTextContainer.innerText = `Replying to ${replyParentSender}: ${replyParentMessageText}`;
replyContainer.appendChild(replyTextContainer);
replyContainer.classList.add("chat-replay-message-reply");
messageTextElement.appendChild(replyContainer);
}
addChatMessageTextToElement(chatMessageData, messageTextElement);
const messageContainer = createMessageContainer(chatMessageData, false);
messageContainer.appendChild(sendTimeElement);
messageContainer.appendChild(senderNameElement);
messageContainer.appendChild(messageTextElement);
return messageContainer;
}
function renderSystemMessages(chatMessageData) {
const chatMessage = chatMessageData.message;
if (chatMessage.command !== "USERNOTICE" || chatMessage.params[0] != `#${globalStreamName}`) {
return [];
}
const messages = [];
const sendTimeElement = document.createElement("div");
sendTimeElement.classList.add("chat-replay-message-time");
sendTimeElement.innerText = chatMessageData.displayWhen;
const systemTextElement = document.createElement("div");
systemTextElement.classList.add("chat-replay-message-text");
systemTextElement.classList.add("chat-replay-message-system");
let systemMsg = chatMessage.tags["system-msg"];
if (!systemMsg && chatMessage.tags["msg-id"] === "announcement") {
systemMsg = "Announcement";
}
systemTextElement.appendChild(document.createTextNode(systemMsg));
const firstMessageContainer = createMessageContainer(chatMessageData, true);
firstMessageContainer.appendChild(sendTimeElement);
firstMessageContainer.appendChild(systemTextElement);
messages.push(firstMessageContainer);
if (chatMessage.params.length === 1) {
return messages;
}
const emptySendTimeElement = document.createElement("div");
emptySendTimeElement.classList.add("chat-replay-message-time");
const senderNameElement = createMessageSenderElement(chatMessageData);
const messageTextElement = document.createElement("div");
messageTextElement.classList.add("chat-replay-message-text");
addChatMessageTextToElement(chatMessageData, messageTextElement);
const secondMessageContainer = createMessageContainer(chatMessageData, false);
secondMessageContainer.appendChild(emptySendTimeElement);
secondMessageContainer.appendChild(senderNameElement);
secondMessageContainer.appendChild(messageTextElement);
messages.push(secondMessageContainer);
return messages;
}
function createMessageContainer(chatMessageData, isSystemMessage) {
const chatMessage = chatMessageData.message;
const messageContainer = document.createElement("div");
messageContainer.classList.add("chat-replay-message");
if (chatMessage.tags.hasOwnProperty("id")) {
if (isSystemMessage) {
messageContainer.id = `chat-replay-message-system-${chatMessage.tags.id}`;
} else {
messageContainer.id = `chat-replay-message-${chatMessage.tags.id}`;
}
}
messageContainer.dataset.sender = chatMessage.sender;
return messageContainer;
}
function getMessageDisplayName(chatMessageData) {
const chatMessage = chatMessageData.message;
if (chatMessage.tags.hasOwnProperty("display-name")) {
return chatMessage.tags["display-name"];
}
return chatMessage.sender;
}
function createMessageSenderElement(chatMessageData) {
const chatMessage = chatMessageData.message;
const senderNameElement = document.createElement("div");
senderNameElement.classList.add("chat-replay-message-sender");
if (chatMessage.tags.hasOwnProperty("color")) {
senderNameElement.style.color = chatMessage.tags.color;
}
senderNameElement.innerText = getMessageDisplayName(chatMessageData);
return senderNameElement;
}
function addChatMessageTextToElement(chatMessageData, messageTextElement) {
const chatMessage = chatMessageData.message;
let chatMessageText = chatMessage.params[1];
if (chatMessageText.startsWith("\u0001ACTION")) {
messageTextElement.classList.add("chat-replay-message-text-action");
const substringEnd = chatMessageText.endsWith("\u0001")
? chatMessageText.length - 1
: chatMessageText.length;
chatMessageText = chatMessageText.substring(7, substringEnd);
}
if (chatMessage.tags.emotes) {
const emoteDataStrings = chatMessage.tags.emotes.split("/");
let emotePositions = [];
for (const emoteDataString of emoteDataStrings) {
const emoteData = emoteDataString.split(":", 2);
const emoteID = emoteData[0];
const emotePositionList = emoteData[1].split(",").map((val) => {
const positions = val.split("-");
return { emote: emoteID, start: +positions[0], end: +positions[1] };
});
emotePositions = emotePositions.concat(emotePositionList);
}
emotePositions.sort((a, b) => a.start - b.start);
let messageText = [chatMessageText];
while (emotePositions.length > 0) {
const emoteData = emotePositions.pop(); // Pop the highest-index element from the array
let text = messageText.shift();
const textAndEmote = [text.substring(0, emoteData.start)];
const emoteImg = document.createElement("img");
emoteImg.src = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteData.emote}/default/dark/1.0`;
const emoteText = text.substring(emoteData.start, emoteData.end + 1);
emoteImg.alt = emoteText;
emoteImg.title = emoteText;
const emoteContainer = document.createElement("span");
emoteContainer.classList.add("chat-replay-message-emote");
emoteContainer.appendChild(emoteImg);
textAndEmote.push(emoteContainer);
const remainingText = text.substring(emoteData.end + 1);
if (remainingText !== "") {
textAndEmote.push(remainingText);
}
messageText = textAndEmote.concat(messageText);
}
for (const messagePart of messageText) {
if (typeof messagePart === "string") {
const node = document.createTextNode(messagePart);
messageTextElement.appendChild(node);
} else {
messageTextElement.appendChild(messagePart);
}
}
} else {
messageTextElement.appendChild(document.createTextNode(chatMessageText));
}
}

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,101 @@
import { DateTime } from "luxon";
export enum TimeType {
UTC,
BusTime,
TimeAgo,
}
export function dateTimeFromWubloaderTime(wubloaderTime: string): DateTime | null {
const dt = DateTime.fromISO(wubloaderTime, { zone: "UTC" });
if (dt.isValid) {
return dt;
}
return null;
}
export function wubloaderTimeFromDateTime(dateTime: DateTime): string {
// Not using ISO here because Luxon doesn't give us a quick way to print an ISO8601 string with no offset.
return dateTime.toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
}
class DateTimeMathObject {
hours: number;
minutes: number;
seconds: number;
}
function dateTimeMathObjectFromBusTime(busTime: string): DateTimeMathObject | null {
// We need to handle inputs like "-0:10:15" in a way that consistently makes the time negative.
// Since we can't assign the negative sign to any particular part, we'll check for the whole thing here.
let direction = 1;
if (busTime.startsWith("-")) {
busTime = busTime.slice(1);
direction = -1;
}
const parts = busTime.split(":", 3);
const hours = parseInt(parts[0], 10) * direction;
const minutes = parts.length > 1 ? parseInt(parts[1], 10) * direction : 0;
const seconds = parts.length > 2 ? +parts[2] * direction : 0;
return { hours: hours, minutes: minutes, seconds: seconds };
}
export function dateTimeFromBusTime(busStartTime: DateTime, busTime: string): DateTime | null {
const busMathObject = dateTimeMathObjectFromBusTime(busTime);
if (busMathObject === null) {
return null;
}
return busStartTime.plus(busMathObject);
}
export function busTimeFromDateTime(busStartTime: DateTime, time: DateTime): string {
const diff = time.diff(busStartTime);
if (diff.milliseconds < 0) {
const negativeInterval = diff.negate();
return `-${negativeInterval.toFormat("hh:mm:ss.SSS")}`;
}
return diff.toFormat("hh:mm:ss.SSS");
}
export function dateTimeFromTimeAgo(timeAgo: string): DateTime | null {
const parts = timeAgo.split(":");
const properties = ["hours", "minutes", "seconds"];
const mathObj = {};
while (parts.length > 0) {
const nextPart = parts.pop();
if (properties.length === 0) {
return null;
}
const nextProp = properties.pop();
const partNumber = +nextPart;
if (isNaN(partNumber)) {
return null;
}
mathObj[nextProp] = partNumber;
}
const now = DateTime.utc();
return now.minus(mathObj);
}
export function timeAgoFromDateTime(dateTime: DateTime): string {
const currentTime = DateTime.utc();
const interval = currentTime.diff(dateTime, "seconds");
let timeAgoSeconds = interval.seconds;
let negative = "";
if (timeAgoSeconds < 0) {
negative = "-";
timeAgoSeconds = -timeAgoSeconds;
}
const seconds = Math.floor((timeAgoSeconds % 60) * 1000) / 1000;
const secondsString = seconds < 10 ? `0${seconds}` : seconds.toString();
const minutes = (timeAgoSeconds / 60) % 60 | 0;
const minutesString = minutes < 10 ? `0${minutes}` : minutes.toString();
const hours = Math.floor(timeAgoSeconds / 3600);
return `${negative}${hours}:${minutesString}:${secondsString}`;
}

@ -0,0 +1,48 @@
import { Component } from "solid-js";
export let googleUser: any = null;
declare var gapi: any; // This is a global we use from the Google Sign In script
function googleOnSignIn(googleUserData) {
googleUser = googleUserData;
const signInElem = document.getElementById("google-auth-sign-in");
if (signInElem) {
signInElem.classList.remove("hidden");
}
const signOutElem = document.getElementById("google-auth-sign-out");
if (signOutElem) {
signOutElem.classList.add("hidden");
}
}
async function googleSignOut() {
if (googleUser) {
googleUser = null;
await gapi.auth2.getAuthInstance().signOut();
const signInElem = document.getElementById("google-auth-sign-in");
if (signInElem) {
signInElem.classList.add("hidden");
}
const signOutElem = document.getElementById("google-auth-sign-out");
if (signOutElem) {
signOutElem.classList.remove("hidden");
}
}
}
// The googleOnSignIn amd googleSignOut functions need to be available to the global scope for Google code to invoke it
(window as any).googleOnSignIn = googleOnSignIn;
(window as any).googleSignOut = googleSignOut;
export const GoogleSignIn: Component = () => {
return (
<div>
<div id="google-auth-sign-in" class="g-signin2" data-onsuccess="googleOnSignIn"></div>
<a href="javascript:googleSignOut" id="google-auth-sign-out" class="hidden">
Sign Out of Google Account
</a>
</div>
);
};

@ -0,0 +1,11 @@
.streamTimeSettings {
display: flex;
align-items: flex-end;
gap: 5px;
margin-bottom: 10px;
margin-top: 5px;
}
.streamTimeSettingLabel {
margin-right: 3px;
}

@ -0,0 +1,29 @@
// Used to make the controls appear below the video player
media-player {
flex-direction: column;
}
media-provider, media-captions, video {
max-width: 100%;
max-height: 50vh;
}
// Used to make the controls appear below the video player
media-player:not([data-fullscreen]) media-controls {
position: relative;
height: auto;
}
media-controls-group {
display: flex;
align-items: center;
width: 100%;
}
media-volume-slider {
flex-basis: 100px;
}
.vds-slider-track-fill {
background-color: #f6f6f6;
}

@ -0,0 +1,396 @@
import {
Accessor,
Component,
createEffect,
createSignal,
For,
onCleanup,
onMount,
Setter,
Show,
} from "solid-js";
import { DateTime } from "luxon";
import {
TimeType,
wubloaderTimeFromDateTime,
busTimeFromDateTime,
timeAgoFromDateTime,
dateTimeFromWubloaderTime,
dateTimeFromBusTime,
dateTimeFromTimeAgo,
} from "./convertTime";
import styles from "./video.module.scss";
import "./video.scss";
import { MediaPlayerElement } from "vidstack/elements";
import "vidstack/icons";
import "vidstack/player/styles/default/theme.css";
import "vidstack/player/styles/default/layouts/video.css";
import "vidstack/player";
import "vidstack/player/layouts/default";
import "vidstack/player/ui";
export const VIDEO_FRAMES_PER_SECOND = 30;
export const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4, 8];
export class StreamVideoInfo {
streamName: string;
streamStartTime: DateTime;
streamEndTime: DateTime | null;
}
export interface StreamTimeSettingsProps {
busStartTime: Accessor<DateTime>;
streamVideoInfo: Accessor<StreamVideoInfo>;
setStreamVideoInfo: Setter<StreamVideoInfo>;
showTimeRangeLink: boolean;
errorList: Accessor<string[]>;
setErrorList: Setter<string[]>;
}
export const StreamTimeSettings: Component<StreamTimeSettingsProps> = (props) => {
const [timeType, setTimeType] = createSignal<TimeType>(TimeType.UTC);
const submitHandler = (event: SubmitEvent) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const streamName = formData.get("stream") as string;
const startTimeEntered = formData.get("start-time") as string;
const endTimeEntered = formData.get("end-time") as string;
const timeType = +formData.get("time-type") as TimeType;
let startTime: DateTime | null = null;
let endTime: DateTime | null = null;
switch (timeType) {
case TimeType.UTC:
startTime = dateTimeFromWubloaderTime(startTimeEntered);
if (endTimeEntered !== "") {
endTime = dateTimeFromWubloaderTime(endTimeEntered);
}
break;
case TimeType.BusTime:
startTime = dateTimeFromBusTime(props.busStartTime(), startTimeEntered);
if (endTimeEntered !== "") {
endTime = dateTimeFromBusTime(props.busStartTime(), endTimeEntered);
}
break;
case TimeType.TimeAgo:
startTime = dateTimeFromTimeAgo(startTimeEntered);
if (endTimeEntered !== "") {
endTime = dateTimeFromTimeAgo(endTimeEntered);
}
break;
}
if (startTime === null || (endTimeEntered !== "" && endTime === null)) {
const error = "A load boundary time could not be parsed. Check the format of your times.";
props.setErrorList([...props.errorList(), error]);
return;
}
props.setStreamVideoInfo({
streamName: streamName,
streamStartTime: startTime,
streamEndTime: endTime,
});
};
const startTimeDisplay = () => {
const startTime = props.streamVideoInfo().streamStartTime;
switch (timeType()) {
case TimeType.UTC:
return wubloaderTimeFromDateTime(startTime);
case TimeType.BusTime:
return busTimeFromDateTime(props.busStartTime(), startTime);
case TimeType.TimeAgo:
return timeAgoFromDateTime(startTime);
}
};
const endTimeDisplay = () => {
const endTime = props.streamVideoInfo().streamEndTime;
if (endTime === null) {
return "";
}
switch (timeType()) {
case TimeType.UTC:
return wubloaderTimeFromDateTime(endTime);
case TimeType.BusTime:
return busTimeFromDateTime(props.busStartTime(), endTime);
case TimeType.TimeAgo:
return timeAgoFromDateTime(endTime);
}
};
const timeRangeLink = () => {
const streamInfo = props.streamVideoInfo();
const startTime = wubloaderTimeFromDateTime(streamInfo.streamStartTime);
const query = new URLSearchParams({
stream: streamInfo.streamName,
start: startTime,
});
if (streamInfo.streamEndTime) {
const endTime = wubloaderTimeFromDateTime(streamInfo.streamEndTime);
query.append("end", endTime);
}
return `?${query}`;
};
return (
<form onSubmit={submitHandler} class={styles.streamTimeSettings}>
<label>
<span class={styles.streamTimeSettingLabel}>Stream</span>
<input type="text" name="stream" value={props.streamVideoInfo().streamName} />
</label>
<label>
<span class={styles.streamTimeSettingLabel}>Start Time</span>
<input type="text" name="start-time" value={startTimeDisplay()} />
</label>
<label>
<span class={styles.streamTimeSettingLabel}>End Time</span>
<input type="text" name="end-time" value={endTimeDisplay()} />
</label>
<div>
<label>
<input
type="radio"
name="time-type"
value={TimeType.UTC}
checked={timeType() === TimeType.UTC}
onClick={(event) => setTimeType(TimeType.UTC)}
/>
UTC
</label>
<label>
<input
type="radio"
name="time-type"
value={TimeType.BusTime}
checked={timeType() === TimeType.BusTime}
onClick={(event) => setTimeType(TimeType.BusTime)}
/>
Bus Time
</label>
<label>
<input
type="radio"
name="time-type"
value={TimeType.TimeAgo}
checked={timeType() === TimeType.TimeAgo}
onClick={(event) => setTimeType(TimeType.TimeAgo)}
/>
Time Ago
</label>
</div>
<div>
<button type="submit">Update Time Range</button>
</div>
<Show when={props.showTimeRangeLink}>
<div>
<a href={timeRangeLink()}>Link to this time range</a>
</div>
</Show>
</form>
);
};
export interface VideoPlayerProps {
src: Accessor<string>;
}
export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
let [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>();
createEffect(() => {
const player = mediaPlayer();
const srcURL = props.src();
player.src = srcURL;
});
let [playerTime, setPlayerTime] = createSignal(0);
let [duration, setDuration] = createSignal(0);
onMount(() => {
const player = mediaPlayer();
player.subscribe(({ currentTime, duration }) => {
setPlayerTime(currentTime);
setDuration(duration);
});
player.streamType = "on-demand";
});
// The <media-time> elements provided by vidstack don't show milliseconds, so
// we need to run our own for millisecond display.
const formatTime = (time: number) => {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time / 60) % 60);
const milliseconds = Math.floor((time % 1) * 1000);
const seconds = Math.floor(time % 60);
const minutesDisplay = minutes.toString().padStart(2, "0");
const secondsDisplay = seconds.toString().padStart(2, "0");
const millisecondsDisplay = milliseconds.toString().padStart(3, "0");
if (hours === 0) {
return `${minutesDisplay}:${secondsDisplay}.${millisecondsDisplay}`;
}
return `${hours}:${minutesDisplay}:${secondsDisplay}.${millisecondsDisplay}`;
};
return (
<media-player
src={props.src()}
ref={setMediaPlayer}
preload="auto"
controlsDelay={0}
storage="thrimbletrimmer"
>
<media-provider
onClick={(event) => {
const player = mediaPlayer();
if (player.paused) {
player.play(event);
} else {
player.pause(event);
}
}}
/>
<media-captions class="vds-captions" />
<media-controls class="vds-controls">
<media-controls-group class="vds-controls-group">
<media-tooltip>
<media-tooltip-trigger>
<media-play-button class="vds-button">
<media-icon type="play" class="vds-play-icon" />
<media-icon type="pause" class="vds-pause-icon" />
</media-play-button>
</media-tooltip-trigger>
<media-tooltip-content class="vds-tooltip-content" placement="top">
<span class="vds-play-tooltip-text">Play</span>
<span class="vds-pause-tooltip-text">Pause</span>
</media-tooltip-content>
</media-tooltip>
<media-tooltip>
<media-tooltip-trigger>
<media-mute-button class="vds-button">
<media-icon type="mute" class="vds-mute-icon" />
<media-icon type="volume-low" class="vds-volume-low-icon" />
<media-icon type="volume-high" class="vds-volume-high-icon" />
</media-mute-button>
</media-tooltip-trigger>
<media-tooltip-content class="vds-tooltip-content" placement="top">
<span class="vds-mute-tooltip-text">Unmute</span>
<span class="vds-unmute-tooltip-text">Mute</span>
</media-tooltip-content>
</media-tooltip>
<media-volume-slider class="vds-slider">
<div class="vds-slider-track"></div>
<div class="vds-slider-track vds-slider-track-fill"></div>
<media-slider-preview class="vds-slider-preview">
<media-slider-value class="vds-slider-value" />
</media-slider-preview>
<div class="vds-slider-thumb"></div>
</media-volume-slider>
<div>
<span>{formatTime(playerTime())}</span>
<span class="vds-time-divider">/</span>
<span>{formatTime(duration())}</span>
</div>
<div class="vds-controls-spacer"></div>
<media-tooltip>
<media-tooltip-trigger>
<media-caption-button class="vds-button">
<media-icon class="vds-cc-on-icon" type="closed-captions-on" />
<media-icon class="vds-cc-off-icon" type="closed-captions" />
</media-caption-button>
</media-tooltip-trigger>
<media-tooltip-content class="vds-tooltip-content" placement="top">
<span class="vds-cc-on-tooltip-text">Turn Closed Captions Off</span>
<span class="vds-cc-off-tooltip-text">Turn Closed Captions On</span>
</media-tooltip-content>
</media-tooltip>
<media-tooltip>
<media-tooltip-trigger>
<media-fullscreen-button class="vds-button">
<media-icon class="vds-fs-enter-icon" type="fullscreen" />
<media-icon class="vds-fs-exit-icon" type="fullscreen-exit" />
</media-fullscreen-button>
</media-tooltip-trigger>
<media-tooltip-content class="vds-tooltip-content" placement="top end">
<span class="vds-fs-enter-tooltip-text">Enter Fullscreen</span>
<span class="vds-fs-exit-tooltip-text">Exit Fullscreen</span>
</media-tooltip-content>
</media-tooltip>
</media-controls-group>
<media-controls-group class="vds-controls-group">
<media-time-slider class="vds-time-slider vds-slider">
<media-slider-chapters class="vds-slider-chapters">
<template>
<div class="vds-slider-chapter">
<div class="vds-slider-track"></div>
<div class="vds-slider-track vds-slider-track-fill"></div>
</div>
</template>
</media-slider-chapters>
<media-slider-preview class="vds-slider-preview">
<media-slider-value class="vds-slider-value" />
</media-slider-preview>
</media-time-slider>
</media-controls-group>
</media-controls>
</media-player>
);
};
export interface KeyboardShortcutProps {
includeEditorShortcuts: boolean;
}
export const KeyboardShortcuts: Component<KeyboardShortcutProps> = (
props: KeyboardShortcutProps,
) => {
return (
<details>
<summary>Keyboard Shortcuts</summary>
<ul>
<li>Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)</li>
<li>K or Space: Toggle pause</li>
<li>M: Toggle mute</li>
<li>J: Back 10 seconds</li>
<li>L: Forward 10 seconds</li>
<li>Left arrow: Back 5 seconds</li>
<li>Right arrow: Forward 5 seconds</li>
<li>Shift+J: Back 1 second</li>
<li>Shift+L: Forward 1 second</li>
<li>Comma (,): Back 1 frame</li>
<li>Period (.): Forward 1 frame</li>
<li>Equals (=): Increase playback speed 1 step</li>
<li>Hyphen (-): Decrease playback speed 1 step</li>
<li>Shift+=: 2x or maximum playback speed</li>
<li>Shift+-: Minimum playback speed</li>
<li>Backspace: Reset playback speed to 1x</li>
<Show when={props.includeEditorShortcuts}>
<li>
Left bracket ([): Set start point for active range (indicated by arrow) to current video
time
</li>
<li>Right bracket (]): Set end point for active range to current video time</li>
<li>O: Set active range one above current active range</li>
<li>
P: Set active range one below current active range, adding a new range if the current
range is the last one
</li>
</Show>
</ul>
</details>
);
};

@ -0,0 +1,7 @@
import "./globalStyle.scss";
import { render } from "solid-js/web";
import Editor from "./editor/Editor";
const root = document.getElementById("root");
render(() => <Editor />, root!);

@ -0,0 +1,7 @@
import { Component } from "solid-js";
const Editor: Component = () => {
return <></>;
};
export default Editor;

@ -0,0 +1,46 @@
body {
// Firefox has a weird default font, which is a different size from the one in Chrome
// and makes some renderings bad.
font-family: "Arial", sans-serif;
background: #222;
color: #fff;
height: 100vh;
margin: 0;
}
a {
color: #ccf;
}
input,
textarea {
background: #222;
color: #fff;
border-color: #444;
}
textarea {
// Text areas look better with the same borders as input fields.
border-style: inset;
border-width: 2px;
}
button,
select {
background: #333;
color: #fff;
}
button:active {
background: #000;
}
a,
.click {
cursor: pointer;
}
.hidden {
display: none;
}

@ -0,0 +1,7 @@
import "./globalStyle.scss";
import { render } from "solid-js/web";
import { Restreamer } from "./restreamer/Restreamer";
const root = document.getElementById("root");
render(() => <Restreamer />, root!);

@ -0,0 +1,21 @@
.errorList {
color: #f33;
display: flex;
flex-direction: column;
}
.errorList > div {
border-bottom: 1px solid #f33;
background: #300;
padding: 4px;
}
.errorRemoveLink {
float: right;
}
.keyboardShortcutHelp {
position: absolute;
top: 0;
right: 0;
}

@ -0,0 +1,125 @@
import {
Accessor,
Component,
createEffect,
createResource,
createSignal,
For,
Setter,
Show,
Suspense,
} from "solid-js";
import { DateTime } from "luxon";
import styles from "./Restreamer.module.scss";
import { dateTimeFromWubloaderTime, wubloaderTimeFromDateTime } from "../common/convertTime";
import {
KeyboardShortcuts,
StreamTimeSettings,
StreamVideoInfo,
VideoPlayer,
} from "../common/video";
export interface DefaultsData {
video_channel: string;
bustime_start: string;
title_prefix: string;
title_max_length: string;
upload_locations: string[];
}
export const Restreamer: Component = () => {
const [pageErrors, setPageErrors] = createSignal<string[]>([]);
const [defaultsData] = createResource<DefaultsData | null>(
async (source, { value, refetching }) => {
const response = await fetch("/thrimshim/defaults");
if (!response.ok) {
return null;
}
return await response.json();
},
);
const busStartTime = () => {
const defaults = defaultsData();
if (defaults && defaults.hasOwnProperty("bustime_start")) {
return dateTimeFromWubloaderTime(defaults.bustime_start);
}
return null;
};
const now = DateTime.utc();
return (
<>
<ul class={styles.errorList}>
<For each={pageErrors()}>
{(error: string, index: Accessor<number>) => (
<li>
{error}
<a class={styles.errorRemoveLink}>[X]</a>
</li>
)}
</For>
</ul>
<div class={styles.keyboardShortcutHelp}>
<KeyboardShortcuts includeEditorShortcuts={false} />
</div>
<Suspense>
<Show when={defaultsData()}>
<RestreamerWithDefaults
defaults={defaultsData()}
errorList={pageErrors}
setErrorList={setPageErrors}
/>
</Show>
</Suspense>
</>
);
};
interface RestreamerDefaultProps {
defaults: DefaultsData;
errorList: Accessor<string[]>;
setErrorList: Setter<string[]>;
}
const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
const [busStartTime, setBusStartTime] = createSignal<DateTime>(
dateTimeFromWubloaderTime(props.defaults.bustime_start),
);
const [streamVideoInfo, setStreamVideoInfo] = createSignal<StreamVideoInfo>({
streamName: props.defaults.video_channel,
streamStartTime: DateTime.utc().minus({ minutes: 10 }),
streamEndTime: null,
});
const videoURL = () => {
const streamInfo = streamVideoInfo();
const startTime = wubloaderTimeFromDateTime(streamInfo.streamStartTime);
const query = new URLSearchParams({ start: startTime });
if (streamInfo.streamEndTime) {
const endTime = wubloaderTimeFromDateTime(streamInfo.streamEndTime);
query.append("end", endTime);
}
const queryString = query.toString();
let url = `/playlist/${streamInfo.streamName}.m3u8`;
if (queryString !== "") {
url += `?${queryString}`;
}
return url;
};
return (
<>
<StreamTimeSettings
busStartTime={busStartTime}
streamVideoInfo={streamVideoInfo}
setStreamVideoInfo={setStreamVideoInfo}
showTimeRangeLink={false}
errorList={props.errorList}
setErrorList={props.setErrorList}
/>
<VideoPlayer src={videoURL} />
</>
);
};

@ -0,0 +1,7 @@
import "./globalStyle.scss";
import { render } from "solid-js/web";
import ThumbnailManager from "./thumbnails/ThumbnailManager";
const root = document.getElementById("root");
render(() => <ThumbnailManager />, root!);

@ -0,0 +1,47 @@
.templatesList {
display: grid;
grid-template-columns: max-content max-content max-content max-content max-content max-content max-content;
gap: 0px;
border-left: 1px solid #000;
}
.templatesListHeader > div {
border-top: 1px solid #000;
font-weight: 700;
}
.templatesListRow {
display: contents;
}
.templatesListRow > div {
border-right: 1px solid #000;
border-bottom: 1px solid #000;
padding: 1px;
}
.templateCoord {
width: 50px;
}
.templateImagePreview {
max-width: 480px;
}
.templateUpdateErrors {
color: #c00;
}
.newTemplateFormFields {
display: grid;
grid-template-columns: max-content max-content;
gap: 1px;
}
.newTemplateFieldLabelContainer {
display: contents;
}
.newTemplateMidText {
margin: 2px;
}

@ -0,0 +1,479 @@
import { Accessor, Component, createSignal, For, Index, onMount, Setter, Show } from "solid-js";
import { GoogleSignIn, googleUser } from "../common/googleAuth";
import styles from "./ThumbnailManager.module.scss";
class Coordinate {
x: number;
y: number;
}
class Template {
name: string;
description: string;
attribution: string;
cropStart: Coordinate;
cropEnd: Coordinate;
locationStart: Coordinate;
locationEnd: Coordinate;
}
const ThumbnailManager: Component = () => {
const [templates, setTemplates] = createSignal<Template[]>([]);
const [newTemplateErrors, setNewTemplateErrors] = createSignal<string[]>([]);
onMount(async () => {
const templateDataResponse = await fetch("/thrimshim/templates");
if (!templateDataResponse.ok) {
return;
}
const templateData = await templateDataResponse.json();
const templateList: Template[] = [];
for (const template of templateData) {
const cropStart = { x: template.crop[0], y: template.crop[1] };
const cropEnd = { x: template.crop[2], y: template.crop[3] };
const locationStart = { x: template.location[0], y: template.location[1] };
const locationEnd = { x: template.location[2], y: template.location[3] };
templateList.push({
name: template.name,
description: template.description,
attribution: template.attribution,
cropStart: cropStart,
cropEnd: cropEnd,
locationStart: locationStart,
locationEnd: locationEnd,
});
}
setTemplates(templateList);
});
const submitHandler = async (
origName: string,
noImageIsError: boolean,
errorList: Accessor<string[]>,
setErrorList: Setter<string[]>,
event: SubmitEvent,
): Promise<Template | null> => {
setErrorList([]);
const form = event.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const attribution = formData.get("attribution") as string;
const cropStartX = parseInt(formData.get("cropstartx") as string, 10);
const cropStartY = parseInt(formData.get("cropstarty") as string, 10);
const cropEndX = parseInt(formData.get("cropendx") as string, 10);
const cropEndY = parseInt(formData.get("cropendy") as string, 10);
const locStartX = parseInt(formData.get("locstartx") as string, 10);
const locStartY = parseInt(formData.get("locstarty") as string, 10);
const locEndX = parseInt(formData.get("locendx") as string, 10);
const locEndY = parseInt(formData.get("locendy") as string, 10);
if (
isNaN(cropStartX) ||
isNaN(cropStartY) ||
isNaN(cropEndX) ||
isNaN(cropEndY) ||
isNaN(locStartX) ||
isNaN(locStartY) ||
isNaN(locEndX) ||
isNaN(locEndY)
) {
setErrorList((errors) => {
errors.push("All crop and location information must be entered.");
return errors;
});
}
const imageFile = formData.get("image") as Blob;
const fileReader = new FileReader();
const fileReaderCompletePromise = new Promise<void>((resolve, reject) => {
fileReader.addEventListener("loadend", (event) => resolve());
});
fileReader.readAsDataURL(imageFile);
await fileReaderCompletePromise;
const submitData = new Map();
submitData.set("name", name);
submitData.set("description", description);
submitData.set("attribution", attribution);
submitData.set("crop", [cropStartX, cropStartY, cropEndX, cropEndY]);
submitData.set("location", [locStartX, locStartY, locEndX, locEndY]);
const imageDataURL = fileReader.result as string;
if (imageDataURL.startsWith("data:image/png;base64,")) {
submitData.set("image", imageDataURL.substring(22));
} else if (noImageIsError) {
setErrorList((errors) => {
errors.push("A PNG image must be selected.");
return errors;
});
}
if (googleUser) {
submitData.set("token", googleUser.getAuthResponse().id_token);
}
if (errorList().length > 0) {
return null;
}
const submitURL =
origName === ""
? "/thrimshim/add-template"
: `/thrimshim/update-template/${encodeURIComponent(origName)}`;
const submitDataJSON = JSON.stringify(Object.fromEntries(submitData));
const submitResponse = await fetch(submitURL, {
method: "POST",
body: submitDataJSON,
headers: { "Content-Type": "application/json" },
});
if (!submitResponse.ok) {
const errorText = await submitResponse.text();
setErrorList((errors) => {
errors.push(errorText);
return errors;
});
}
form.reset();
return {
name: name,
description: description,
attribution: attribution,
cropStart: { x: cropStartX, y: cropStartY },
cropEnd: { x: cropEndX, y: cropEndY },
locationStart: { x: locStartX, y: locStartY },
locationEnd: { x: locEndX, y: locEndY },
};
};
return (
<>
<div class={styles.templatesList}>
<div class={`${styles.templatesListRow} ${styles.templatesListHeader}`}>
<div>Name</div>
<div>Description</div>
<div>Attribution</div>
<div>Crop Coordiates</div>
<div>Location Coordinates</div>
<div>Preview</div>
<div></div>
</div>
<Index each={templates()}>
{(template: Accessor<Template>, index: number) => {
const [formErrors, setFormErrors] = createSignal<string[]>([]);
const [displayImagePreview, setDisplayImagePreview] = createSignal(false);
const [editing, setEditing] = createSignal(false);
return (
<form
class={styles.templatesListRow}
onSubmit={async (event) => {
const submitData = await submitHandler(
template().name,
false,
formErrors,
setFormErrors,
event,
);
if (submitData) {
setTemplates((templateList) => {
templateList[index] = submitData;
return templateList;
});
}
}}
>
<Show
when={editing()}
fallback={
<>
<div>{template().name}</div>
<div>{template().description}</div>
<div>{template().attribution}</div>
<div>
({template().cropStart.x}, {template().cropStart.y}) to (
{template().cropEnd.x}, {template().cropEnd.y})
</div>
<div>
({template().locationStart.x}, {template().locationStart.y}) to (
{template().locationEnd.x}, {template().locationEnd.y})
</div>
<div>
<Show
when={displayImagePreview()}
fallback={
<a href="#" onClick={(event) => setDisplayImagePreview(true)}>
Preview
</a>
}
>
<img
class={styles.templateImagePreview}
src={`/thrimshim/template/${encodeURIComponent(template().name)}.png`}
/>
</Show>
</div>
<div>
<button type="button" onClick={(event) => setEditing(true)}>
Edit
</button>
</div>
</>
}
>
<>
<div>
<input type="text" name="name" value={template().name} />
</div>
<div>
<textarea name="description">{template().description}</textarea>
</div>
<div>
<input type="text" name="attribution" value={template().attribution} />
</div>
<div>
(
<input
type="number"
name="cropstartx"
placeholder="X"
min={0}
step={1}
class={styles.templateCoord}
value={template().cropStart.x}
/>
,
<input
type="number"
name="cropstarty"
placeholder="Y"
min={0}
step={1}
class={styles.templateCoord}
value={template().cropStart.y}
/>
)
<br />
(
<input
type="number"
name="cropendx"
placeholder="X"
min={0}
step={1}
class={styles.templateCoord}
value={template().cropEnd.x}
/>
,
<input
type="number"
name="cropendy"
placeholder="Y"
min={0}
step={1}
class={styles.templateCoord}
value={template().cropEnd.y}
/>
)
</div>
<div>
(
<input
type="number"
name="locstartx"
placeholder="X"
min={0}
step={1}
class={styles.templateCoord}
value={template().locationStart.x}
/>
,
<input
type="number"
name="locstarty"
placeholder="Y"
min={0}
step={1}
class={styles.templateCoord}
value={template().locationStart.y}
/>
)
<br />
(
<input
type="number"
name="locendx"
placeholder="X"
min={0}
step={1}
class={styles.templateCoord}
value={template().locationEnd.x}
/>
,
<input
type="number"
name="locendy"
placeholder="Y"
min={0}
step={1}
class={styles.templateCoord}
value={template().locationEnd.y}
/>
)
</div>
<div>
<input type="file" name="image" accept="image/png" />
</div>
<div>
<button type="submit">Submit</button>
<button type="button" onClick={(event) => setEditing(false)}>
Cancel
</button>
<ul class={styles.templateUpdateErrors}>
<For each={formErrors()}>
{(error: string, index: Accessor<number>) => <li>{error}</li>}
</For>
</ul>
</div>
</>
</Show>
</form>
);
}}
</Index>
</div>
<form
onSubmit={async (event) => {
const submitData = await submitHandler(
"",
true,
newTemplateErrors,
setNewTemplateErrors,
event,
);
if (submitData) {
setTemplates((templateList) => [...templateList, submitData]);
}
}}
>
<h1>Add New Template</h1>
<ul class={styles.templateUpdateErrors}>
<For each={newTemplateErrors()}>
{(error: string, index: Accessor<number>) => <li>{error}</li>}
</For>
</ul>
<div class={styles.newTemplateFormFields}>
<label class={styles.newTemplateFieldLabelContainer}>
<span>Name:</span>
<input type="text" name="name" />
</label>
<label class={styles.newTemplateFieldLabelContainer}>
<span>Image:</span>
<input type="file" name="image" accept="image/png" />
</label>
<label class={styles.newTemplateFieldLabelContainer}>
<span>Description:</span>
<textarea name="description"></textarea>
</label>
<label class={styles.newTemplateFieldLabelContainer}>
<span>Attribution:</span>
<input type="text" name="attribution" />
</label>
<span>Crop:</span>
<span>
<input
type="number"
class={styles.templateCoord}
name="cropstartx"
placeholder="X"
min={0}
step={1}
value={182}
/>
<input
type="number"
class={styles.templateCoord}
name="cropstarty"
placeholder="Y"
min={0}
step={1}
value={0}
/>
<span class={styles.newTemplateMidText}>to</span>
<input
type="number"
class={styles.templateCoord}
name="cropendx"
placeholder="X"
min={0}
step={1}
value={1738}
/>
<input
type="number"
class={styles.templateCoord}
name="cropendy"
placeholder="Y"
min={0}
step={1}
value={824}
/>
</span>
<span class={styles.newTemplateInlineLabel}>Location:</span>
<span>
<input
type="number"
class={styles.templateCoord}
name="locstartx"
placeholder="X"
min={0}
step={1}
value={45}
/>
<input
type="number"
class={styles.templateCoord}
name="locstarty"
placeholder="Y"
min={0}
step={1}
value={45}
/>
<span class={styles.newTemplateMidText}>to</span>
<input
type="number"
class={styles.templateCoord}
name="locendx"
placeholder="X"
min={0}
step={1}
value={1235}
/>
<input
type="number"
class={styles.templateCoord}
name="locendy"
placeholder="Y"
min={0}
step={1}
value={675}
/>
</span>
</div>
<button type="submit">Add Template</button>
<button type="reset">Reset</button>
</form>
<GoogleSignIn />
</>
);
};
export default ThumbnailManager;

@ -0,0 +1,52 @@
import { Accessor, Component, createSignal, onCleanup, onMount } from "solid-js";
import { DateTime, Interval } from "luxon";
interface ClockProps {
busStartTime: Accessor<DateTime | null>;
}
const Clock: Component<ClockProps> = (props) => {
const [delay, setDelay] = createSignal<number>(10);
const [time, setTime] = createSignal<DateTime>(DateTime.utc());
const busStartTime = props.busStartTime;
const timer = setInterval(() => setTime(DateTime.utc()), 250);
onCleanup(() => {
clearInterval(timer);
});
const timeDisplay = () => {
const currentTime = time().minus({ seconds: delay() });
const busTime = busStartTime();
if (!busTime) {
return "";
}
const [timeElapsed, sign] =
currentTime >= busTime
? [Interval.fromDateTimes(busTime, currentTime).toDuration("seconds"), ""]
: [Interval.fromDateTimes(currentTime, busTime).toDuration("seconds"), "-"];
const timeElapsedString = timeElapsed.toFormat("h:mm:ss");
return `${sign}${timeElapsedString}`;
};
return (
<div>
<div>{timeDisplay()}</div>
<div>
<input
type="number"
value={delay()}
min={0}
step={1}
onInput={(event) => setDelay(+event.currentTarget.value)}
/>
&#32;seconds of delay
</div>
</div>
);
};
export default Clock;

@ -0,0 +1,131 @@
import { Accessor, Component, createSignal } from "solid-js";
import { DateTime } from "luxon";
import {
dateTimeFromWubloaderTime,
dateTimeFromBusTime,
dateTimeFromTimeAgo,
wubloaderTimeFromDateTime,
busTimeFromDateTime,
timeAgoFromDateTime,
TimeType,
} from "../common/convertTime";
interface TimeConverterProps {
busStartTime: Accessor<DateTime | null>;
}
const TimeConverter: Component<TimeConverterProps> = (props) => {
const [enteredTime, setEnteredTime] = createSignal<string>("");
const [startTimeType, setStartTimeType] = createSignal<TimeType>(TimeType.UTC);
const [outputTimeType, setOutputTimeType] = createSignal<TimeType>(TimeType.UTC);
const outputString = (): string => {
const busStartTime = props.busStartTime();
if (busStartTime === null) {
return "";
}
const startType = startTimeType();
let dateTime: DateTime | null = null;
if (startType === TimeType.UTC) {
dateTime = dateTimeFromWubloaderTime(enteredTime());
} else if (startType === TimeType.BusTime) {
dateTime = dateTimeFromBusTime(busStartTime, enteredTime());
} else if (startType === TimeType.TimeAgo) {
dateTime = dateTimeFromTimeAgo(enteredTime());
}
if (dateTime === null) {
return "";
}
const outputType = outputTimeType();
if (outputType === TimeType.UTC) {
return wubloaderTimeFromDateTime(dateTime);
}
if (outputType === TimeType.BusTime) {
return busTimeFromDateTime(busStartTime, dateTime);
}
if (outputType === TimeType.TimeAgo) {
return timeAgoFromDateTime(dateTime);
}
return "";
};
return (
<div>
<h1>Convert Times</h1>
<input
type="text"
placeholder="Time to convert"
value={enteredTime()}
onInput={(event) => {
setEnteredTime(event.currentTarget.value);
}}
/>
<div>
From:
<label>
<input
name="time-converter-from"
type="radio"
value={TimeType.UTC}
checked={true}
onClick={(event) => setStartTimeType(TimeType.UTC)}
/>
UTC
</label>
<label>
<input
name="time-converter-from"
type="radio"
value={TimeType.BusTime}
onClick={(event) => setStartTimeType(TimeType.BusTime)}
/>
Bus Time
</label>
<label>
<input
name="time-converter-from"
type="radio"
value={TimeType.TimeAgo}
onClick={(event) => setStartTimeType(TimeType.TimeAgo)}
/>
Time Ago
</label>
</div>
<div>
To:
<label>
<input
name="time-converter-to"
type="radio"
checked={true}
value={TimeType.UTC}
onClick={(event) => setOutputTimeType(TimeType.UTC)}
/>
UTC
</label>
<label>
<input
name="time-converter-to"
type="radio"
value={TimeType.BusTime}
onClick={(event) => setOutputTimeType(TimeType.BusTime)}
/>
Bus Time
</label>
<label>
<input
name="time-converter-to"
type="radio"
value={TimeType.TimeAgo}
onClick={(event) => setOutputTimeType(TimeType.TimeAgo)}
/>
Time Ago
</label>
</div>
<div>Converted Time: {outputString()}</div>
</div>
);
};
export default TimeConverter;

@ -0,0 +1,23 @@
import { Component, createSignal, onMount } from "solid-js";
import { DateTime } from "luxon";
import Clock from "./Clock";
import TimeConverter from "./TimeConverter";
const Utilities: Component = () => {
const [busStartTime, setBusStartTime] = createSignal<DateTime | null>(null);
onMount(async () => {
const dataResponse = await fetch("/thrimshim/defaults");
const data = await dataResponse.json();
setBusStartTime(DateTime.fromISO(data.bustime_start));
});
return (
<>
<Clock busStartTime={busStartTime} />
<TimeConverter busStartTime={busStartTime} />
</>
);
};
export default Utilities;

@ -0,0 +1,7 @@
import "./globalStyle.scss";
import { render } from "solid-js/web";
import Utilities from "./utilities/Utilities";
const root = document.getElementById("root");
render(() => <Utilities />, root!);

@ -1,173 +0,0 @@
.jcrop-widget .jcrop-handle {
display: none;
position: absolute;
border: 1px rgba(127, 127, 127, 0.8) solid;
width: 10px;
height: 10px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8)
}
.jcrop-widget .jcrop-handle.nw {
top: -3px;
left: -3px;
cursor: nwse-resize
}
.jcrop-widget .jcrop-handle.w {
top: 50%;
transform: translateY(-50%);
left: -3px;
cursor: ew-resize
}
.jcrop-widget .jcrop-handle.sw {
bottom: -3px;
left: -3px;
cursor: nesw-resize
}
.jcrop-widget .jcrop-handle.ne {
top: -3px;
right: -3px;
cursor: nesw-resize
}
.jcrop-widget .jcrop-handle.e {
top: 50%;
transform: translateY(-50%);
right: -3px;
cursor: ew-resize
}
.jcrop-widget .jcrop-handle.se {
bottom: -3px;
right: -3px;
cursor: nwse-resize
}
.jcrop-widget .jcrop-handle.n {
left: 50%;
transform: translateX(-50%);
top: -3px;
cursor: ns-resize
}
.jcrop-widget .jcrop-handle.s {
left: 50%;
transform: translateX(-50%);
bottom: -3px;
cursor: ns-resize
}
.jcrop-widget.active .jcrop-handle {
display: block
}
.jcrop-widget {
position: absolute;
box-sizing: border-box;
border: 1px white dashed;
opacity: 0.7;
background: transparent;
transition: opacity 1s;
padding: 0;
margin: 0;
cursor: move
}
.jcrop-widget:hover {
transition: opacity 0.8s;
opacity: 0.8
}
.jcrop-shade {
background: rgba(0, 0, 0, 0.5);
transition: opacity 0.4s, background-color 0.7s;
position: absolute
}
.jcrop-shade.l {
top: 0px;
left: 0px;
height: 100%
}
.jcrop-shade.r {
top: 0px;
right: 0px;
height: 100%
}
.jcrop-shade.t {
top: 0px
}
.jcrop-shade.b {
bottom: 0px
}
.jcrop-stage {
position: relative;
width: 100%
}
.jcrop-image-stage img {
position: absolute;
z-index: -1
}
.jcrop-ux-inactive-handles .jcrop-widget .jcrop-handle {
display: block
}
.jcrop-widget img {
width: 100%;
height: auto
}
.jcrop-ux-fade-more .jcrop-widget {
opacity: 0.25
}
.jcrop-ux-fade-more .jcrop-widget:hover {
transition: opacity 0.4s;
opacity: 0.8
}
.jcrop-ux-fade-more .jcrop-widget:focus {
transition: opacity 0.5s;
opacity: 1;
outline-style: auto;
outline-width: 3px;
outline-color: rgba(0, 0, 0, 0.3)
}
.jcrop-ux-fade-more .jcrop-widget {
opacity: 0.25
}
.jcrop-ux-fade-more .jcrop-widget:hover {
opacity: 0.65
}
.jcrop-ux-keep-current .jcrop-widget.active {
opacity: 1;
outline-style: auto;
outline-width: 3px;
outline-color: rgba(0, 0, 0, 0.3)
}
.jcrop-ux-no-outline .jcrop-widget {
outline: none !important
}
.jcrop-disable.jcrop-stage {
opacity: .8
}
.jcrop-disable.jcrop-stage .jcrop-widget {
outline: none !important
}
/*# sourceMappingURL=jcrop.css.map */

@ -1,496 +0,0 @@
body {
/* Firefox has a weird default font, which is a different size from the one in Chrome
* and makes some renderings bad.
*/
font-family: "Arial", sans-serif;
background: #222;
color: #fff;
height: 100vh;
margin: 0;
}
a {
color: #ccf;
}
input,
textarea {
background: #222;
color: #fff;
border-color: #444;
}
textarea {
/* This will look better if it's consistent with input fields */
border-style: inset;
border-width: 2px;
}
button,
select {
background: #333;
color: #fff;
}
button:active {
background: #000;
}
a,
.click {
cursor: pointer;
}
a.click {
text-decoration: underline;
}
.input-error {
border-color: #b00;
}
.input-error:focus {
outline: #d00 solid 1px;
}
#errors {
color: #f33;
display: flex;
flex-direction: column;
}
#errors > div {
border-bottom: 1px solid #f33;
background: #300;
padding: 4px;
}
.error-dismiss {
float: right;
}
#page-container {
position: relative;
max-height: 100vh;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
#page-container > * {
flex: 0 1 auto;
}
#editor-help {
position: absolute;
top: 0;
right: 0;
background: #222;
padding: 5px;
}
#stream-time-settings {
display: flex;
align-items: flex-end;
gap: 5px;
margin-bottom: 10px;
margin-top: 5px;
}
#stream-time-settings > div {
margin: 0 2px;
}
.field-label {
display: block;
}
#video {
width: 100%;
max-height: 50vh;
}
#video-controls {
font-size: 110%;
}
#video-controls select {
appearance: none;
font-size: inherit;
background: inherit;
border: none;
text-align: center;
}
#video-controls option {
background: #222;
padding: 2px;
}
#video-controls-bar {
display: flex;
align-items: center;
gap: 8px;
}
#video-controls-spacer {
flex-grow: 1;
}
#video-controls-volume {
display: flex;
align-items: center;
gap: 2px;
}
#video-controls-volume-level {
width: 100px;
height: 8px;
border-radius: 0;
border: 0;
}
#video-controls-playback-position {
height: 10px;
border-radius: 0;
border: 0;
}
/* For some reason, there's not a cross-browser way to style <progress> elements.
* This should be replaced with a cross-browser way of doing this when possible.
* I only implemented WebKit/Blink and Firefox here because if you still use IE,
* I quite frankly don't care about you.
*/
/* WEBKIT/BLINK SECTION */
#video-controls-volume-level::-webkit-progress-bar {
background: #ffffff30;
}
#video-controls-volume-level::-webkit-progress-value {
background: #ffffffc0;
}
#video-controls-playback-position::-webkit-progress-bar {
background: #ffffff30;
}
#video-controls-playback-position::-webkit-progress-value {
background: #ffffffc0;
}
/* END WEBKIT/BLINK */
/* FIREFOX SECTION */
#video-controls-volume-level {
background: #ffffff30;
}
#video-controls-volume-level::-moz-progress-bar {
background: #ffffffc0;
}
#video-controls-playback-position {
background: #ffffff30;
}
#video-controls-playback-position::-moz-progress-bar {
background: #ffffffc0;
}
/* END FIREFOX */
#video-controls-playback-position {
width: 100%;
}
#clip-bar {
width: 100%;
min-height: 7px;
background-color: #bbb;
position: relative;
}
#clip-bar > div {
position: absolute;
background-color: #d80;
height: 100%;
}
#waveform-container {
position: relative;
}
#waveform {
width: 100%;
/* With an unbound height, the waveform can appear a bit away from the video.
* The intended effect still works if we scrunch the height a bit, so here's
* a height bound for the waveform image.
*/
max-height: 100px;
filter: invert(90%);
}
#waveform-marker {
width: 1px;
height: 100%;
background: #dd8800a0;
position: absolute;
top: 0;
}
#range-definitions {
display: flex;
flex-direction: column;
gap: 1px;
}
.range-transition-duration-section {
display: inline-block;
}
.range-transition-duration {
width: 50px;
}
.range-definition-times {
display: flex;
align-items: center;
gap: 4px;
}
.range-definition-start,
.range-definition-end {
width: 100px;
text-align: right;
}
.range-definition-between-time-gap {
width: 5px;
}
.range-definition-icon-gap {
width: 16px;
}
#add-range-definition {
margin-top: 2px;
}
.range-definition-chapter-markers > div {
display: flex;
align-items: center;
gap: 10px;
margin-left: 30px;
}
.range-definition-chapter-marker-start-field {
display: flex;
align-items: center;
gap: 4px;
}
.range-definition-chapter-marker-start {
width: 100px;
text-align: right;
}
.range-definition-chapter-marker-edit-gap {
width: 16px;
}
input.range-definition-chapter-marker-description {
width: 500px;
}
.add-range-definition-chapter-marker {
margin-left: 30px;
margin-bottom: 7px;
}
#video-info {
margin: 5px 0;
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: minmax(min-content, max-content) 1.25em 3em minmax(4em, max-content) 1.25em;
gap: 2px;
}
#video-info-editor-notes-container {
border: 1px solid #666;
background-color: #125;
grid-column-end: span 2;
}
/* In order to maintain the grid dimensions, when we hide the editors notes (for there not being them),
* they still need to take up a grid slot. As such, we replace `display: none` in this context with
* an effective equivalent that doesn't remove its rendering entirely.
*/
#video-info-editor-notes-container.hidden {
display: block;
visibility: hidden;
height: 0;
}
#video-info-title-full {
display: flex;
align-items: center;
white-space: pre;
}
#video-info-title {
flex-grow: 1;
}
#video-info-title-abbreviated {
width: 200px;
overflow: hidden;
font-size: 1em;
line-height: 1em;
height: 2em;
text-overflow: ellipsis;
/* For some reason, all this Webkit-specific-looking stuff is required to show ellipses
on wrapped text.
It also somehow works on Firefox. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.video-info-thumbnail-mode-options {
margin: 2px 0;
}
.video-info-thumbnail-position {
width: 50px;
}
#video-info-thumbnail-template-preview-image {
max-width: 320px;
}
#video-info-thumbnail-template-video-source-image {
max-width: 640px;
}
#video-info-thumbnail-template-overlay-image {
max-width: 640px;
}
.video-info-thumbnail-advanced-crop-flex-wrapper {
display: flex;
align-items: center;
}
.video-info-thumbnail-advanced-crop-flex-column {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.video-info-thumbnail-advanced-crop-flex-column div {
margin: 0.5em;
}
.submission-response-error {
white-space: pre-wrap;
}
.hidden {
display: none;
}
#submission {
margin: 5px 0;
}
#download {
margin: 5px 0;
}
#data-correction {
margin: 5px 0;
}
#data-correction-force-reset-confirm p {
margin: 5px 0;
}
.submission-response-pending {
color: #cc0;
}
.submission-response-error {
color: #c00;
}
.submission-response-success {
color: #0c0;
}
.time-converter-time {
display: block;
width: 200px;
}
#chat-replay {
overflow-y: auto;
min-height: 250px;
}
.chat-replay-message {
display: flex;
align-items: baseline;
gap: 10px;
}
.chat-replay-message-time {
flex-basis: 110px;
color: #ccc;
text-align: right;
}
.chat-replay-message-text {
flex-basis: 200px;
flex-grow: 1;
}
.chat-replay-message-text-action {
font-style: italic;
}
.chat-replay-message-system {
color: #aaf;
}
.chat-replay-message-text-action .chat-replay-message-reply:not(.chat-replay-message-text-action) {
font-style: normal; /* Clear the italics from the action */
}
.chat-replay-message-emote {
/*
This size is set based on Twitch's 1.0 emote size.
This will need to be updated if that changes. (Otherwise, auto-scrolling will break.)
*/
width: 28px;
height: 28px;
}
.chat-replay-message-reply {
font-size: 80%;
}
.chat-replay-message-reply a {
text-decoration: none;
}
.chat-replay-message-cleared {
opacity: 0.5;
}
.chat-replay-message-cleared .chat-replay-message-text {
text-decoration: line-through;
}

@ -1,43 +0,0 @@
.hidden {
display: none;
}
table > form {
display: table-row;
}
#template-list-data {
border-collapse: collapse;
}
#template-list-data td {
border: 1px solid #000;
padding: 2px;
}
.template-list-preview {
width: 480px;
}
#template-new-errors,
.template-data-edit-errors {
color: #c00;
}
#template-new-form-fields {
display: grid;
grid-template-columns: max-content max-content;
gap: 1px;
}
#template-new-form-fields > div {
display: contents;
}
.template-coord {
width: 50px;
}
#google-authentication {
margin-top: 5px;
}

@ -1,155 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>VST Thumbnail Template Management</title>
<meta
name="google-signin-client_id"
content="345276493482-r84m2giavk10glnmqna0lbq8e1hdaus0.apps.googleusercontent.com"
/>
<script src="https://apis.google.com/js/platform.js?onload=onGLoad" async defer></script>
<link rel="stylesheet" href="styles/thumbnails.css" />
<script src="scripts/thumbnails.js"></script>
</head>
<body>
<div id="template-list">
<h1>Template List</h1>
<table id="template-list-data">
<tr>
<th>Name</th>
<th>Description</th>
<th>Attribution</th>
<th>Crop Coordinates</th>
<th>Location Coordinates</th>
<th>Preview</th>
<th></th>
</tr>
</table>
</div>
<div id="template-new">
<h1>Add New Template</h1>
<ul id="template-new-errors"></ul>
<form id="template-new-form">
<div id="template-new-form-fields">
<div>
<label for="template-new-name">Name:</label>
<input type="text" name="name" id="template-new-name" />
</div>
<div>
<label for="template-new-image">Image:</label>
<input type="file" id="template-new-image" name="image" accept="image/png" />
</div>
<div>
<label for="template-new-description">Description:</label>
<textarea id="template-new-description" name="description"></textarea>
</div>
<div>
<label for="template-new-attribution">Attribution:</label>
<input type="text" id="template-new-attribution" name="attribution" />
</div>
<div>
<span>Crop:</span>
<span>
<input
type="number"
class="template-coord"
id="template-new-crop-x-start"
name="cropxstart"
placeholder="X"
min="0"
step="1"
value="182"
/>
<input
type="number"
class="template-coord"
id="template-new-crop-y-start"
name="cropystart"
placeholder="Y"
min="0"
step="1"
value="0"
/>
to
<input
type="number"
class="template-coord"
id="template-new-crop-x-end"
name="cropxend"
placeholder="X"
min="0"
step="1"
value="1738"
/>
<input
type="number"
class="template-coord"
id="template-new-crop-y-end"
name="cropyend"
placeholder="Y"
min="0"
step="1"
value="824"
/>
</span>
</div>
<div>
<span>Location:</span>
<span>
<input
type="number"
class="template-coord"
id="template-new-location-x-start"
name="locxstart"
placeholder="X"
min="0"
step="1"
value="45"
/>
<input
type="number"
class="template-coord"
id="template-new-location-y-start"
name="locystart"
placeholder="Y"
min="0"
step="1"
value="45"
/>
to
<input
type="number"
class="template-coord"
id="template-new-location-x-end"
name="locxend"
placeholder="X"
min="0"
step="1"
value="1235"
/>
<input
type="number"
class="template-coord"
id="template-new-location-y-end"
name="locyend"
placeholder="Y"
min="0"
step="1"
value="675"
/>
</span>
</div>
</div>
<button type="submit">Add Template</button>
</form>
</div>
<div id="google-authentication">
<div id="google-auth-sign-in" class="g-signin2" data-onsuccess="googleOnSignIn"></div>
<a href="#" id="google-auth-sign-out" class="hidden">Sign Out of Google Account</a>
</div>
</body>
</html>

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Thrimbletrimmer - Utilities</title>
<meta
name="google-signin-client_id"
content="345276493482-r84m2giavk10glnmqna0lbq8e1hdaus0.apps.googleusercontent.com"
/>
<script src="https://apis.google.com/js/platform.js?onload=onGLoad" async defer></script>
</head>
<body>
<div id="root"></div>
<script src="/src/thumbnails.tsx" type="module"></script>
</body>
</html>

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vidstack/solid", "vite/client"],
"noEmit": true,
"isolatedModules": true
}
}

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Thrimbletrimmer - Utilities</title>
</head>
<body>
<div id="root"></div>
<script src="/src/utils.tsx" type="module"></script>
</body>
</html>

@ -0,0 +1,24 @@
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import devtools from "solid-devtools/vite";
export default defineConfig({
base: "/thrimbletrimmer/",
plugins: [devtools(), solidPlugin()],
server: {
port: 3000,
},
build: {
target: "esnext",
// minify: false, // Uncomment this line if you need to debug unminified code
rollupOptions: {
input: {
index: fileURLToPath(new URL("index.html", import.meta.url)),
edit: fileURLToPath(new URL("edit.html", import.meta.url)),
utils: fileURLToPath(new URL("utils.html", import.meta.url)),
thumbnails: fileURLToPath(new URL("thumbnails.html", import.meta.url)),
},
},
},
});
Loading…
Cancel
Save