Initial structure and clock
This is the start of replacing the old Thrimbletrimmer with a new SolidJS-based Thrimbletrimmer. It includes an implementation of the clock page for a basic implementation of signal-based page.thrimbletrimmer-solid
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
package-lock.json
|
@ -1,4 +1 @@
|
||||
scripts/hls.min.js
|
||||
scripts/luxon.min.js
|
||||
scripts/jcrop.js
|
||||
styles/jcrop.css
|
||||
src/external
|
@ -1,56 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Stream Time</title>
|
||||
<style type="text/css">
|
||||
#clock {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="clock"></div>
|
||||
<div><input type="number" id="delay" value="10" min="0" /> seconds of delay</div>
|
||||
<script type="text/javascript">
|
||||
let busStartTime = null;
|
||||
|
||||
function updateClock() {
|
||||
let delay = parseInt(document.getElementById("delay").value);
|
||||
if (isNaN(delay)) {
|
||||
delay = 0;
|
||||
}
|
||||
let time = (new Date() - busStartTime) / 1000 - delay;
|
||||
|
||||
let sign = "";
|
||||
if (time < 0) {
|
||||
time = -time;
|
||||
sign = "-";
|
||||
}
|
||||
|
||||
let hours = Math.trunc(time / 3600).toString();
|
||||
let mins = Math.trunc((time % 3600) / 60).toString();
|
||||
let secs = Math.trunc(time % 60).toString();
|
||||
|
||||
if (mins.length < 2) {
|
||||
mins = "0" + mins;
|
||||
}
|
||||
if (secs.length < 2) {
|
||||
secs = "0" + secs;
|
||||
}
|
||||
let formatted = sign + hours + ":" + mins + ":" + secs;
|
||||
document.getElementById("clock").innerText = formatted;
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const dataResponse = await fetch("/thrimshim/defaults");
|
||||
const data = await dataResponse.json();
|
||||
busStartTime = new Date(data.bustime_start);
|
||||
|
||||
setInterval(updateClock, 1000);
|
||||
}
|
||||
|
||||
initialize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Before Width: | Height: | Size: 439 B |
Before Width: | Height: | Size: 520 B |
Before Width: | Height: | Size: 196 B |
Before Width: | Height: | Size: 196 B |
Before Width: | Height: | Size: 196 B |
Before Width: | Height: | Size: 196 B |
@ -1,54 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<style>
|
||||
#road-container {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
// margin: 980px 0 0 -100px; // uncomment to move to bottom of screen
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
#road-container div {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
background-position: 0px 0px;
|
||||
}
|
||||
|
||||
#timeofday-left {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
#timeofday-right {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
#stops {
|
||||
z-index: 6;
|
||||
background-image: url(stops.png);
|
||||
}
|
||||
|
||||
#bus {
|
||||
background-repeat: no-repeat;
|
||||
margin-left: 27px;
|
||||
z-index: 8;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="road-container">
|
||||
<div id="bus"></div>
|
||||
<div id="timeofday-left"></div>
|
||||
<div id="timeofday-right"></div>
|
||||
<div id="stops"></div>
|
||||
</div>
|
||||
|
||||
<script src="drive.js"></script>
|
||||
|
||||
</body>
|
||||
|
@ -1,122 +0,0 @@
|
||||
|
||||
const PAGE_WIDTH = 1920;
|
||||
const MINUTES_PER_PAGE = 60;
|
||||
const POINT_WIDTH = PAGE_WIDTH * 8 * 60 / MINUTES_PER_PAGE;
|
||||
const MILES_PER_PAGE = 45;
|
||||
const BUS_POSITION_X = 93;
|
||||
const BASE_ODO = 109.3;
|
||||
const UPDATE_INTERVAL_MS = 5000
|
||||
const WUBLOADER_URL = "";
|
||||
const SKY_URLS = {
|
||||
day: "db_day.png",
|
||||
dawn: "db_dawn.png",
|
||||
dusk: "db_dusk.png",
|
||||
night: "db_night.png",
|
||||
};
|
||||
const BUS_URLS = {
|
||||
day: "bus_day.png",
|
||||
dawn: "bus_day.png",
|
||||
dusk: "bus_day.png",
|
||||
night: "bus_night.png",
|
||||
};
|
||||
|
||||
function setSkyElements(left, right, timeToTransition) {
|
||||
const leftElement = document.getElementById("timeofday-left");
|
||||
const rightElement = document.getElementById("timeofday-right");
|
||||
const busElement = document.getElementById("bus");
|
||||
|
||||
leftElement.style.backgroundImage = `url(${SKY_URLS[left]})`;
|
||||
rightElement.style.backgroundImage = `url(${SKY_URLS[right]})`;
|
||||
|
||||
if (left === right) {
|
||||
leftElement.style.width = "100%";
|
||||
} else {
|
||||
const transitionPercent = timeToTransition / MINUTES_PER_PAGE;
|
||||
leftElement.style.width = `${transitionPercent * 100}%`
|
||||
}
|
||||
|
||||
bus.style.backgroundImage = `url(${BUS_URLS[left]})`;
|
||||
}
|
||||
|
||||
function nextSkyTransition(timeofday, clock) {
|
||||
switch (timeofday) {
|
||||
case "dawn":
|
||||
case "day":
|
||||
return [19 * 60, "dusk"]; // 7pm
|
||||
case "dusk":
|
||||
return [20 * 60, "night"]; // 8pm
|
||||
case "night":
|
||||
return [6 * 60 + 40, "dawn"]; // 6:40am
|
||||
}
|
||||
}
|
||||
|
||||
function setSky(timeofday, clock) {
|
||||
const [transition, newSky] = nextSkyTransition(timeofday, clock);
|
||||
// 1440 minutes in 24h, this code will return time remaining even if
|
||||
// the transition is in the morning and we're currently in the evening.
|
||||
const timeToTransition = (1440 + transition - clock) % 1440;
|
||||
if (timeToTransition < MINUTES_PER_PAGE) {
|
||||
// Transition on screen
|
||||
setSkyElements(timeofday, newSky, timeToTransition);
|
||||
} else {
|
||||
// No transition on screen
|
||||
setSkyElements(timeofday, timeofday, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function setOdo(odo) {
|
||||
const distancePixels = PAGE_WIDTH * (odo - BASE_ODO) / MILES_PER_PAGE;
|
||||
const offset = (BUS_POSITION_X - distancePixels) % POINT_WIDTH;
|
||||
|
||||
const stopsElement = document.getElementById("stops");
|
||||
stopsElement.style.backgroundPosition = `${offset}px 0px`;
|
||||
}
|
||||
|
||||
async function update() {
|
||||
const busDataResponse = await fetch(`${WUBLOADER_URL}/thrimshim/bus/buscam`);
|
||||
if (!busDataResponse.ok) {
|
||||
return;
|
||||
}
|
||||
const busData = await busDataResponse.json();
|
||||
console.log("Got data:", busData);
|
||||
setOdo(busData.odometer);
|
||||
setSky(busData.timeofday, busData.clock_minutes);
|
||||
}
|
||||
|
||||
// Initial conditions, before the first refresh finishes
|
||||
setSky("day", 7 * 60);
|
||||
setOdo(BASE_ODO);
|
||||
|
||||
// Testing mode. Set true to enable.
|
||||
const test = false;
|
||||
if (test) {
|
||||
let h = 0;
|
||||
// Set to how long 1h of in-game time should take in real time
|
||||
const hourTimeMs = 1 * 1000;
|
||||
// Set to how often to update the screen
|
||||
const interval = 30;
|
||||
setInterval(() => {
|
||||
h += interval / hourTimeMs;
|
||||
setOdo(BASE_ODO + 45 * h);
|
||||
if (h < 19) {
|
||||
setSky("day", 60 * h);
|
||||
} else {
|
||||
m = (h % 24) * 60;
|
||||
let tod;
|
||||
if (m < 6 * 60 + 40) {
|
||||
tod = "night";
|
||||
} else if (m < 19 * 60) {
|
||||
tod = "dawn";
|
||||
} else if (m < 20 * 60) {
|
||||
tod = "dusk";
|
||||
} else {
|
||||
tod = "night";
|
||||
}
|
||||
setSky(tod, m);
|
||||
}
|
||||
}, interval);
|
||||
} else {
|
||||
// Do first update immediately, then every UPDATE_INTERVAL_MS
|
||||
setInterval(update, UPDATE_INTERVAL_MS);
|
||||
update();
|
||||
}
|
Before Width: | Height: | Size: 7.2 KiB |
@ -1,485 +1,12 @@
|
||||
<!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>
|
||||
|
||||
<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>
|
||||
<title>Thrimbletrimmer - Editor</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>
|
||||
<li>
|
||||
Left bracket ([): Set start point for active range (indicated by arrow) to active video
|
||||
time
|
||||
</li>
|
||||
<li>Right bracket (]): Set end point for active range to active video time</li>
|
||||
<li>O: Set active range one above current active range</li>
|
||||
<li>
|
||||
P: Set active range one below current active range, adding a new range if the current
|
||||
active range is the last one
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<form id="stream-time-settings">
|
||||
<div>
|
||||
<span class="field-label">Stream</span>
|
||||
<span id="stream-time-setting-stream"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label for="stream-time-setting-start" class="field-label">Start Time</label>
|
||||
<input type="text" id="stream-time-setting-start" />
|
||||
<button id="stream-time-setting-start-pad" type="button">Pad 1 minute</button>
|
||||
</div>
|
||||
<div>
|
||||
<label for="stream-time-setting-end" class="field-label">End Time</label>
|
||||
<input type="text" id="stream-time-setting-end" />
|
||||
<button id="stream-time-setting-end-pad" type="button">Pad 1 minute</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" id="stream-time-settings-submit">Update Time Range</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<video id="video" preload="auto"></video>
|
||||
|
||||
<div id="video-controls">
|
||||
<div id="video-controls-bar">
|
||||
<div>
|
||||
<img
|
||||
id="video-controls-play-pause"
|
||||
src="images/video-controls/play.png"
|
||||
class="click"
|
||||
/>
|
||||
</div>
|
||||
<div id="video-controls-time">
|
||||
<span id="video-controls-current-time"></span>
|
||||
/
|
||||
<span id="video-controls-duration"></span>
|
||||
</div>
|
||||
<div id="video-controls-spacer"></div>
|
||||
<div id="video-controls-volume">
|
||||
<img
|
||||
id="video-controls-volume-mute"
|
||||
src="images/video-controls/volume.png"
|
||||
class="click"
|
||||
/>
|
||||
<progress id="video-controls-volume-level" value="0.5" class="click"></progress>
|
||||
</div>
|
||||
<div>
|
||||
<select id="video-controls-playback-speed"></select>
|
||||
</div>
|
||||
<div>
|
||||
<select id="video-controls-quality"></select>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
id="video-controls-fullscreen"
|
||||
src="images/video-controls/fullscreen.png"
|
||||
class="click"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<progress id="video-controls-playback-position" value="0" class="click"></progress>
|
||||
</div>
|
||||
|
||||
<div id="clip-bar"></div>
|
||||
<div id="waveform-container">
|
||||
<img id="waveform" alt="Waveform for the video" />
|
||||
<div id="waveform-marker"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" id="enable-chapter-markers" />
|
||||
<label for="enable-chapter-markers">Add chapter markers to video description</label>
|
||||
</div>
|
||||
<div>
|
||||
<div id="range-definitions">
|
||||
<div>
|
||||
<div class="range-definition-times">
|
||||
<input type="text" class="range-definition-start" />
|
||||
<img
|
||||
src="images/pencil.png"
|
||||
alt="Set range start point to the current video time"
|
||||
title="Set range start point to the current video time"
|
||||
class="range-definition-set-start click"
|
||||
/>
|
||||
<img
|
||||
src="images/play_to.png"
|
||||
alt="Play from start point"
|
||||
title="Play from start point"
|
||||
class="range-definition-play-start click"
|
||||
/>
|
||||
<div class="range-definition-between-time-gap"></div>
|
||||
<input type="text" class="range-definition-end" />
|
||||
<img
|
||||
src="images/pencil.png"
|
||||
alt="Set range end point to the current video time"
|
||||
title="Set range end point to the current video time"
|
||||
class="range-definition-set-end click"
|
||||
/>
|
||||
<img
|
||||
src="images/play_to.png"
|
||||
alt="Play from end point"
|
||||
title="Play from range end point"
|
||||
class="range-definition-play-end click"
|
||||
/>
|
||||
<div class="range-definition-icon-gap"></div>
|
||||
<img
|
||||
src="images/arrow.png"
|
||||
alt="Range affected by keyboard shortcuts"
|
||||
title="Range affected by keyboard shortcuts"
|
||||
class="range-definition-current"
|
||||
/>
|
||||
</div>
|
||||
<div class="range-definition-chapter-markers hidden">
|
||||
<div>
|
||||
<div class="range-definition-chapter-marker-start-field">
|
||||
<input
|
||||
type="text"
|
||||
class="range-definition-chapter-marker-start"
|
||||
id="range-definition-chapter-marker-first-start"
|
||||
disabled
|
||||
/>
|
||||
<div class="range-definition-chapter-marker-edit-gap"></div>
|
||||
<img
|
||||
src="images/play_to.png"
|
||||
alt="Play from chapter start time"
|
||||
title="Play from chapter start time"
|
||||
class="range-definition-chapter-marker-play-start click"
|
||||
id="range-definition-chapter-marker-first-play-start"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="range-definition-chapter-marker-description"
|
||||
id="range-definition-chapter-marker-first-description"
|
||||
placeholder="Description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
src="images/plus.png"
|
||||
alt="Add chapter marker"
|
||||
title="Add chapter marker"
|
||||
class="add-range-definition-chapter-marker click hidden"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
src="images/plus.png"
|
||||
alt="Add range"
|
||||
id="add-range-definition"
|
||||
class="click"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="video-info">
|
||||
<div id="video-info-editor-notes-container" class="hidden">
|
||||
<div id="video-info-editor-notes-header">Notes to Editor:</div>
|
||||
<div id="video-info-editor-notes"></div>
|
||||
</div>
|
||||
<label for="video-info-title">Title:</label>
|
||||
<div id="video-info-title-full">
|
||||
<span id="video-info-title-prefix"></span>
|
||||
<input type="text" id="video-info-title" />
|
||||
</div>
|
||||
<label>Abbreviated title:</label>
|
||||
<div id="video-info-title-abbreviated"></div>
|
||||
<label for="video-info-description">Description:</label>
|
||||
<textarea id="video-info-description"></textarea>
|
||||
<label for="video-info-tags">Tags (comma-separated):</label>
|
||||
<input type="text" id="video-info-tags" />
|
||||
<label for="video-info-thumbnail-mode">Thumbnail:</label>
|
||||
<div id="video-info-thumbnail">
|
||||
<div>
|
||||
<select id="video-info-thumbnail-mode">
|
||||
<option value="NONE">No custom thumbnail</option>
|
||||
<option value="BARE">Use video frame</option>
|
||||
<option value="TEMPLATE" selected>Use video frame in image template</option>
|
||||
<option value="ONEOFF">Use video frame with a custom one-off overlay</option>
|
||||
<option value="CUSTOM">Use a custom thumbnail image</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="video-info-thumbnail-mode-options" id="video-info-thumbnail-template-options">
|
||||
<select id="video-info-thumbnail-template"></select>
|
||||
</div>
|
||||
<div class="video-info-thumbnail-mode-options" id="video-info-thumbnail-time-options">
|
||||
<input type="text" id="video-info-thumbnail-time" />
|
||||
<img
|
||||
src="images/pencil.png"
|
||||
alt="Set video thumbnail frame to current video time"
|
||||
class="click"
|
||||
id="video-info-thumbnail-time-set"
|
||||
/>
|
||||
<img
|
||||
src="images/play_to.png"
|
||||
alt="Set video time to video thumbnail frame"
|
||||
class="click"
|
||||
id="video-info-thumbnail-time-play"
|
||||
/>
|
||||
</div>
|
||||
<div class="video-info-thumbnail-mode-options" id="video-info-thumbnail-position-options">
|
||||
<details>
|
||||
<summary>Advanced Templating Options</summary>
|
||||
Crop specifies the region of the video frame to capture. <br />
|
||||
Location specifies the region within the template image where the cropped image will
|
||||
be placed. <br />
|
||||
Regions are given as pixel coordinates of the top-left and bottom-right corners.
|
||||
<br />
|
||||
Note that if the regions are different sizes, the image will be stretched. <br />
|
||||
|
||||
<button id="video-info-thumbnail-template-source-image-update">
|
||||
Update Source Images
|
||||
</button>
|
||||
<button id="video-info-thumbnail-template-default-crop">
|
||||
Reset Crop To Defaults
|
||||
</button>
|
||||
<br />
|
||||
|
||||
<div class="video-info-thumbnail-advanced-crop-flex-wrapper">
|
||||
<div class="video-info-thumbnail-advanced-crop-flex-item">
|
||||
<img
|
||||
id="video-info-thumbnail-template-video-source-image"
|
||||
class="hidden"
|
||||
alt="Thumbnail preview image"
|
||||
height="360"
|
||||
width="640"
|
||||
/>
|
||||
<br />
|
||||
Crop:
|
||||
<input
|
||||
type="text"
|
||||
class="video-info-thumbnail-position"
|
||||
id="video-info-thumbnail-crop-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="video-info-thumbnail-position"
|
||||
id="video-info-thumbnail-crop-1"
|
||||
/>
|
||||
to
|
||||
<input
|
||||
type="text"
|
||||
class="video-info-thumbnail-position"
|
||||
id="video-info-thumbnail-crop-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="video-info-thumbnail-position"
|
||||
id="video-info-thumbnail-crop-3"
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="video-info-thumbnail-advanced-crop-flex-item hidden"
|
||||
id="video-info-thumbnail-aspect-ratio-controls"
|
||||
>
|
||||
<div class="video-info-thumbnail-advanced-crop-flex-column">
|
||||
<div>Aspect Ratio</div>
|
||||
<div>
|
||||
<button id="video-info-thumbnail-aspect-ratio-match-right">
|
||||
--Match->
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button id="video-info-thumbnail-aspect-ratio-match-left">
|
||||
<-Match--
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" checked id="video-info-thumbnail-lock-aspect-ratio" />
|
||||
Lock
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-info-thumbnail-advanced-crop-flex-item">
|
||||
<img
|
||||
id="video-info-thumbnail-template-overlay-image"
|
||||
class="hidden"
|
||||
alt="Thumbnail preview image"
|
||||
height="360"
|
||||
width="640"
|
||||
/>
|
||||
<br />
|
||||
Location:
|
||||
<input
|
||||
type="text"
|
||||
class="video-info-thumbnail-position"
|
||||
id="video-info-thumbnail-location-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="video-info-thumbnail-position"
|
||||
id="video-info-thumbnail-location-1"
|
||||
/>
|
||||
to
|
||||
<input
|
||||
type="text"
|
||||
class="video-info-thumbnail-position"
|
||||
id="video-info-thumbnail-location-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="video-info-thumbnail-position"
|
||||
id="video-info-thumbnail-location-3"
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div
|
||||
class="hidden video-info-thumbnail-mode-options"
|
||||
id="video-info-thumbnail-custom-options"
|
||||
>
|
||||
<input type="file" id="video-info-thumbnail-custom" accept="image/png" />
|
||||
</div>
|
||||
<div class="video-info-thumbnail-mode-options" id="video-info-thumbnail-template-preview">
|
||||
<button id="video-info-thumbnail-template-preview-generate">
|
||||
Generate Thumbnail Preview
|
||||
</button>
|
||||
<div>
|
||||
<img
|
||||
id="video-info-thumbnail-template-preview-image"
|
||||
class="hidden"
|
||||
alt="Thumbnail preview image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="submission">
|
||||
<div id="submission-toolbar">
|
||||
<button id="submit-button">Submit</button>
|
||||
<button id="save-button">Save Draft</button>
|
||||
<button id="submit-changes-button" class="hidden">Submit Changes</button>
|
||||
<a id="advanced-submission" href="#">Advanced Submission Options</a>
|
||||
</div>
|
||||
<div id="advanced-submission-options" class="hidden">
|
||||
<div>
|
||||
<label for="advanced-submission-option-allow-holes">Allow holes</label>
|
||||
<input type="checkbox" id="advanced-submission-option-allow-holes" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="advanced-submission-option-unlisted">Make unlisted</label>
|
||||
<input type="checkbox" id="advanced-submission-option-unlisted" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="advanced-submission-option-upload-location">Upload location:</label>
|
||||
<select id="advanced-submission-option-upload-location"></select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="advanced-submission-option-uploader-allow">Uploader allowlist:</label>
|
||||
<input type="text" id="advanced-submission-option-uploader-allow" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="submission-response"></div>
|
||||
</div>
|
||||
|
||||
<div id="download">
|
||||
<label for="download-type-select">Download type:</label>
|
||||
<select id="download-type-select">
|
||||
<option value="smart" selected>Smart (experimental but preferred option)</option>
|
||||
<option value="rough">Rough (raw content, pads start and end by a few seconds)</option>
|
||||
<option value="fast">Fast (deprecated, use if smart is broken)</option>
|
||||
<option value="mpegts">MPEG-TS (slow, consumes server resources)</option>
|
||||
</select>
|
||||
<a id="download-link">Download Video</a>
|
||||
<a href="#" id="download-frame">Download Current Frame as Image</a>
|
||||
</div>
|
||||
|
||||
<div id="data-correction">
|
||||
<div id="data-correction-toolbar">
|
||||
<a id="manual-link-update" class="click">Manual Link Update</a>
|
||||
|
|
||||
<a id="cancel-video-upload" class="click">Cancel Upload</a>
|
||||
|
|
||||
<a id="reset-entire-video" class="click">Force Reset Row</a>
|
||||
</div>
|
||||
<div id="data-correction-manual-link" class="hidden">
|
||||
<input type="text" id="data-correction-manual-link-entry" />
|
||||
<label for="data-correction-manual-link-youtube"
|
||||
>Is YouTube upload (add to playlists)?</label
|
||||
>
|
||||
<input type="checkbox" id="data-correction-manual-link-youtube" />
|
||||
<button id="data-correction-manual-link-submit">Set Link</button>
|
||||
<div id="data-correction-manual-link-response"></div>
|
||||
</div>
|
||||
<div id="data-correction-force-reset-confirm" class="hidden">
|
||||
<p>Are you sure you want to reset this event?</p>
|
||||
<p>
|
||||
This will set the row back to Unedited and forget about any video that may already
|
||||
exist.
|
||||
</p>
|
||||
<p>
|
||||
This is intended as a last-ditch effort to clear a malfunctioning cutter, or if a video
|
||||
needs to be reedited and replaced.
|
||||
</p>
|
||||
<p>
|
||||
<strong
|
||||
>It is your responsibility to deal with any video that may have already been
|
||||
uploaded.</strong
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<button id="data-correction-force-reset-yes">Yes, reset it!</button>
|
||||
<button id="data-correction-force-reset-no">Oh, never mind!</button>
|
||||
</p>
|
||||
</div>
|
||||
<div id="data-correction-cancel-response"></div>
|
||||
</div>
|
||||
|
||||
<div id="google-authentication">
|
||||
<div id="google-auth-sign-in" class="g-signin2" data-onsuccess="googleOnSignIn"></div>
|
||||
<a href="#" id="google-auth-sign-out" class="hidden">Sign Out of Google Account</a>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
|
||||
<div id="chat-replay"></div>
|
||||
</div>
|
||||
<script src="/src/edit.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 637 B |
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 633 B |
Before Width: | Height: | Size: 600 B |
Before Width: | Height: | Size: 667 B |
Before Width: | Height: | Size: 756 B |
Before Width: | Height: | Size: 747 B |
@ -1,170 +1,12 @@
|
||||
<!doctype html>
|
||||
<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,24 @@
|
||||
{
|
||||
"name": "thrimbletrimmer",
|
||||
"version": "4.0.0",
|
||||
"description": "Video editor frontend for Wubloader",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"sass": "1.80.6",
|
||||
"solid-devtools": "0.30.1",
|
||||
"typescript": "5.6.3",
|
||||
"url": "0.11.4",
|
||||
"vite": "5.4.10",
|
||||
"vite-plugin-solid": "2.10.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "1.9.3"
|
||||
}
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
self.importScripts("luxon.min.js", "common-worker.js");
|
||||
|
||||
var DateTime = luxon.DateTime;
|
||||
luxon.Settings.defaultZone = "utc";
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const chatLoadData = event.data;
|
||||
|
||||
const segmentMetadata = chatLoadData.segmentMetadata;
|
||||
for (const segmentData of segmentMetadata) {
|
||||
segmentData.rawStart = DateTime.fromMillis(segmentData.rawStart);
|
||||
segmentData.rawEnd = DateTime.fromMillis(segmentData.rawEnd);
|
||||
}
|
||||
|
||||
const fetchURL = `/${chatLoadData.stream}/chat.json?start=${chatLoadData.start}&end=${chatLoadData.end}`;
|
||||
const chatResponse = await fetch(fetchURL);
|
||||
if (!chatResponse.ok) {
|
||||
return;
|
||||
}
|
||||
const chatRawData = await chatResponse.json();
|
||||
|
||||
const chatData = [];
|
||||
for (const chatLine of chatRawData) {
|
||||
if (
|
||||
chatLine.command !== "PRIVMSG" &&
|
||||
chatLine.command !== "CLEARMSG" &&
|
||||
chatLine.command !== "CLEARCHAT" &&
|
||||
chatLine.command !== "USERNOTICE"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const when = DateTime.fromSeconds(chatLine.time);
|
||||
const displayWhen = videoHumanTimeFromDateTimeWithFragments(segmentMetadata, when);
|
||||
// Here, we just push each line successively into the list. This assumes data is provided to us in chronological order.
|
||||
chatData.push({ message: chatLine, when: when.toMillis(), displayWhen: displayWhen });
|
||||
}
|
||||
self.postMessage(chatData);
|
||||
};
|
||||
|
||||
function videoHumanTimeFromDateTimeWithFragments(fragmentMetadata, dateTime) {
|
||||
for (const segmentData of fragmentMetadata) {
|
||||
if (dateTime >= segmentData.rawStart && dateTime <= segmentData.rawEnd) {
|
||||
const playerTime =
|
||||
segmentData.playerStart + dateTime.diff(segmentData.rawStart).as("seconds");
|
||||
return videoHumanTimeFromVideoPlayerTime(playerTime);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) {
|
||||
const hours = Math.floor(videoPlayerTime / 3600);
|
||||
let minutes = Math.floor((videoPlayerTime % 3600) / 60);
|
||||
let seconds = Math.floor(videoPlayerTime % 60);
|
||||
let milliseconds = Math.floor((videoPlayerTime * 1000) % 1000);
|
||||
|
||||
while (minutes.toString().length < 2) {
|
||||
minutes = `0${minutes}`;
|
||||
}
|
||||
while (seconds.toString().length < 2) {
|
||||
seconds = `0${seconds}`;
|
||||
}
|
||||
while (milliseconds.toString().length < 3) {
|
||||
milliseconds = `0${milliseconds}`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
return `${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
@ -1,736 +0,0 @@
|
||||
var DateTime = luxon.DateTime;
|
||||
var Interval = luxon.Interval;
|
||||
luxon.Settings.defaultZone = "utc";
|
||||
|
||||
var globalBusStartTime = DateTime.fromISO("1970-01-01T00:00:00");
|
||||
var globalStreamName = "";
|
||||
var globalStartTimeString = "";
|
||||
var globalEndTimeString = "";
|
||||
|
||||
var globalPlayer = null;
|
||||
var globalSetUpControls = false;
|
||||
var globalSeekTimer = null;
|
||||
|
||||
var globalChatData = [];
|
||||
var globalLoadChatWorker = null;
|
||||
|
||||
Hls.DefaultConfig.maxBufferHole = 600;
|
||||
|
||||
const VIDEO_FRAMES_PER_SECOND = 30;
|
||||
|
||||
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4, 8];
|
||||
|
||||
function commonPageSetup() {
|
||||
if (!Hls.isSupported()) {
|
||||
addError(
|
||||
"Your browser doesn't support MediaSource extensions. Video playback and editing won't work.",
|
||||
);
|
||||
}
|
||||
|
||||
globalLoadChatWorker = new Worker("scripts/chat-load.js");
|
||||
}
|
||||
|
||||
function addError(errorText) {
|
||||
const errorElement = document.createElement("div");
|
||||
errorElement.innerText = errorText;
|
||||
|
||||
const dismissElement = document.createElement("a");
|
||||
dismissElement.classList.add("error-dismiss");
|
||||
dismissElement.innerText = "[X]";
|
||||
errorElement.appendChild(dismissElement);
|
||||
dismissElement.addEventListener("click", (event) => {
|
||||
const errorHost = document.getElementById("errors");
|
||||
errorHost.removeChild(errorElement);
|
||||
});
|
||||
|
||||
const errorHost = document.getElementById("errors");
|
||||
errorHost.appendChild(errorElement);
|
||||
}
|
||||
|
||||
async function loadVideoPlayer(playlistURL) {
|
||||
let rangedPlaylistURL = assembleVideoPlaylistURL(playlistURL);
|
||||
const videoElement = document.getElementById("video");
|
||||
|
||||
videoElement.addEventListener("loadedmetadata", (_event) => {
|
||||
setUpVideoControls();
|
||||
sendChatLogLoadData();
|
||||
});
|
||||
|
||||
videoElement.addEventListener("loadeddata", (_event) => {
|
||||
const qualitySelector = document.getElementById("video-controls-quality");
|
||||
globalPlayer.currentLevel = +qualitySelector.value;
|
||||
});
|
||||
|
||||
globalPlayer = new Hls();
|
||||
globalPlayer.attachMedia(video);
|
||||
return new Promise((resolve, _reject) => {
|
||||
globalPlayer.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
const startTime = getStartTime();
|
||||
const endTime = getEndTime();
|
||||
if (endTime && endTime.diff(startTime).milliseconds < 0) {
|
||||
addError(
|
||||
"End time is before the start time. This will prevent video loading and cause other problems.",
|
||||
);
|
||||
}
|
||||
globalPlayer.loadSource(rangedPlaylistURL);
|
||||
|
||||
globalPlayer.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
if (data.reason === "no level found in manifest") {
|
||||
addError(
|
||||
"There is no video data between the specified start and end times. Change the times so that there is video content to play.",
|
||||
);
|
||||
} else {
|
||||
console.log("A fatal network error occurred; retrying", data);
|
||||
globalPlayer.startLoad();
|
||||
}
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.log("A fatal media error occurred; retrying", data);
|
||||
globalPlayer.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.log("A fatal error occurred; resetting video player", data);
|
||||
addError(
|
||||
"Some sort of video player error occurred. Thrimbletrimmer is resetting the video player.",
|
||||
);
|
||||
resetVideoPlayer();
|
||||
}
|
||||
} else {
|
||||
console.log("A non-fatal video player error occurred; HLS.js will retry", data);
|
||||
}
|
||||
});
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadVideoPlayerFromDefaultPlaylist() {
|
||||
const playlistURL = `/playlist/${globalStreamName}.m3u8`;
|
||||
await loadVideoPlayer(playlistURL);
|
||||
}
|
||||
|
||||
function resetVideoPlayer() {
|
||||
updateSegmentPlaylist();
|
||||
}
|
||||
|
||||
function updateSegmentPlaylist() {
|
||||
const videoElement = document.getElementById("video");
|
||||
const currentPlaybackRate = videoElement.playbackRate;
|
||||
globalPlayer.destroy();
|
||||
loadVideoPlayerFromDefaultPlaylist();
|
||||
// The playback rate isn't maintained when destroying and reattaching hls.js
|
||||
videoElement.playbackRate = currentPlaybackRate;
|
||||
}
|
||||
|
||||
function setUpVideoControls() {
|
||||
// Setting this up so it's removed from the event doesn't work; loadedmetadata fires twice anyway.
|
||||
// We still need to prevent double-setup, so here we are.
|
||||
if (globalSetUpControls) {
|
||||
return;
|
||||
}
|
||||
globalSetUpControls = true;
|
||||
|
||||
const videoElement = document.getElementById("video");
|
||||
|
||||
const playPauseButton = document.getElementById("video-controls-play-pause");
|
||||
if (videoElement.paused) {
|
||||
playPauseButton.src = "images/video-controls/play.png";
|
||||
} else {
|
||||
playPauseButton.src = "images/video-controls/pause.png";
|
||||
}
|
||||
|
||||
const togglePlayState = (_event) => {
|
||||
if (videoElement.paused) {
|
||||
videoElement.play();
|
||||
} else {
|
||||
videoElement.pause();
|
||||
}
|
||||
};
|
||||
playPauseButton.addEventListener("click", togglePlayState);
|
||||
videoElement.addEventListener("click", (event) => {
|
||||
if (!videoElement.controls) {
|
||||
togglePlayState(event);
|
||||
}
|
||||
});
|
||||
|
||||
videoElement.addEventListener("play", (_event) => {
|
||||
playPauseButton.src = "images/video-controls/pause.png";
|
||||
});
|
||||
videoElement.addEventListener("pause", (_event) => {
|
||||
playPauseButton.src = "images/video-controls/play.png";
|
||||
});
|
||||
|
||||
const currentTime = document.getElementById("video-controls-current-time");
|
||||
currentTime.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
|
||||
videoElement.addEventListener("timeupdate", (_event) => {
|
||||
currentTime.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
|
||||
});
|
||||
|
||||
const duration = document.getElementById("video-controls-duration");
|
||||
duration.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.duration);
|
||||
videoElement.addEventListener("durationchange", (_event) => {
|
||||
duration.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.duration);
|
||||
});
|
||||
|
||||
const volumeMuted = document.getElementById("video-controls-volume-mute");
|
||||
if (videoElement.muted) {
|
||||
volumeMuted.src = "images/video-controls/volume-mute.png";
|
||||
} else {
|
||||
volumeMuted.src = "images/video-controls/volume.png";
|
||||
}
|
||||
const volumeLevel = document.getElementById("video-controls-volume-level");
|
||||
const defaultVolume = +(localStorage.getItem("volume") ?? 0.5);
|
||||
if (isNaN(defaultVolume)) {
|
||||
defaultVolume = 0.5;
|
||||
} else if (defaultVolume < 0) {
|
||||
defaultVolume = 0;
|
||||
} else if (defaultVolume > 1) {
|
||||
defaultVolume = 1;
|
||||
}
|
||||
videoElement.volume = defaultVolume;
|
||||
volumeLevel.value = videoElement.volume;
|
||||
|
||||
volumeMuted.addEventListener("click", (_event) => {
|
||||
videoElement.muted = !videoElement.muted;
|
||||
});
|
||||
volumeLevel.addEventListener("click", (event) => {
|
||||
videoElement.volume = event.offsetX / event.target.offsetWidth;
|
||||
videoElement.muted = false;
|
||||
});
|
||||
videoElement.addEventListener("volumechange", (_event) => {
|
||||
if (videoElement.muted) {
|
||||
volumeMuted.src = "images/video-controls/volume-mute.png";
|
||||
} else {
|
||||
volumeMuted.src = "images/video-controls/volume.png";
|
||||
}
|
||||
volumeLevel.value = videoElement.volume;
|
||||
localStorage.setItem("volume", videoElement.volume);
|
||||
});
|
||||
|
||||
const playbackSpeed = document.getElementById("video-controls-playback-speed");
|
||||
for (const speed of PLAYBACK_RATES) {
|
||||
const speedOption = document.createElement("option");
|
||||
speedOption.value = speed;
|
||||
speedOption.innerText = `${speed}x`;
|
||||
if (speed === 1) {
|
||||
speedOption.selected = true;
|
||||
}
|
||||
playbackSpeed.appendChild(speedOption);
|
||||
}
|
||||
playbackSpeed.addEventListener("change", (_event) => {
|
||||
const speed = +playbackSpeed.value;
|
||||
videoElement.playbackRate = speed;
|
||||
});
|
||||
|
||||
const quality = document.getElementById("video-controls-quality");
|
||||
const defaultQuality = localStorage.getItem("quality");
|
||||
for (const [qualityIndex, qualityLevel] of globalPlayer.levels.entries()) {
|
||||
const qualityOption = document.createElement("option");
|
||||
qualityOption.value = qualityIndex;
|
||||
qualityOption.innerText = qualityLevel.name;
|
||||
if (qualityLevel.name === defaultQuality) {
|
||||
qualityOption.selected = true;
|
||||
}
|
||||
quality.appendChild(qualityOption);
|
||||
}
|
||||
localStorage.setItem("quality", quality.options[quality.options.selectedIndex].innerText);
|
||||
quality.addEventListener("change", (_event) => {
|
||||
globalPlayer.currentLevel = +quality.value;
|
||||
localStorage.setItem("quality", quality.options[quality.options.selectedIndex].innerText);
|
||||
});
|
||||
|
||||
const fullscreen = document.getElementById("video-controls-fullscreen");
|
||||
fullscreen.addEventListener("click", (_event) => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoElement.requestFullscreen();
|
||||
}
|
||||
});
|
||||
videoElement.addEventListener("fullscreenchange", (_event) => {
|
||||
if (document.fullscreenElement) {
|
||||
videoElement.controls = true;
|
||||
} else {
|
||||
videoElement.controls = false;
|
||||
}
|
||||
});
|
||||
|
||||
const playbackPosition = document.getElementById("video-controls-playback-position");
|
||||
playbackPosition.max = videoElement.duration;
|
||||
playbackPosition.value = videoElement.currentTime;
|
||||
videoElement.addEventListener("durationchange", (_event) => {
|
||||
playbackPosition.max = videoElement.duration;
|
||||
});
|
||||
videoElement.addEventListener("timeupdate", (_event) => {
|
||||
playbackPosition.value = videoElement.currentTime;
|
||||
});
|
||||
playbackPosition.addEventListener("click", (event) => {
|
||||
const newPosition = (event.offsetX / event.target.offsetWidth) * videoElement.duration;
|
||||
videoElement.currentTime = newPosition;
|
||||
playbackPosition.value = newPosition;
|
||||
});
|
||||
|
||||
/* Sometimes a mysterious issue occurs loading segments of the video when seeking.
|
||||
* When this happens, twiddling the qualities tends to fix it. Here, we attempt to
|
||||
* detect this situation and fix it automatically.
|
||||
*/
|
||||
videoElement.addEventListener("seeking", (_event) => {
|
||||
// If we don't get a "seeked" event soon after the "seeking" event, we assume there's
|
||||
// a loading error.
|
||||
// To handle this, we set up a timed handler to pick this up.
|
||||
if (globalSeekTimer !== null) {
|
||||
clearTimeout(globalSeekTimer);
|
||||
globalSeekTimer = null;
|
||||
}
|
||||
globalSeekTimer = setTimeout(() => {
|
||||
const currentLevel = globalPlayer.currentLevel;
|
||||
globalPlayer.currentLevel = -1;
|
||||
globalPlayer.currentLevel = currentLevel;
|
||||
}, 500);
|
||||
});
|
||||
videoElement.addEventListener("seeked", (_event) => {
|
||||
// Since we got the seek, cancel the timed twiddling of qualities
|
||||
if (globalSeekTimer !== null) {
|
||||
clearTimeout(globalSeekTimer);
|
||||
globalSeekTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function dateTimeMathObjectFromBusTime(busTime) {
|
||||
// We need to handle inputs like "-0:10:15" in a way that consistently makes the time negative.
|
||||
// Since we can't assign the negative sign to any particular part, we'll check for the whole thing here.
|
||||
let direction = 1;
|
||||
if (busTime.startsWith("-")) {
|
||||
busTime = busTime.slice(1);
|
||||
direction = -1;
|
||||
}
|
||||
|
||||
const parts = busTime.split(":", 3);
|
||||
const hours = parseInt(parts[0]) * direction;
|
||||
const minutes = (parts[1] || 0) * direction;
|
||||
const seconds = (parts[2] || 0) * direction;
|
||||
return { hours: hours, minutes: minutes, seconds: seconds };
|
||||
}
|
||||
|
||||
function dateTimeFromBusTime(busTime) {
|
||||
return globalBusStartTime.plus(dateTimeMathObjectFromBusTime(busTime));
|
||||
}
|
||||
|
||||
function busTimeFromDateTime(dateTime) {
|
||||
const diff = dateTime.diff(globalBusStartTime);
|
||||
return formatIntervalForDisplay(diff);
|
||||
}
|
||||
|
||||
function formatIntervalForDisplay(interval) {
|
||||
if (interval.milliseconds < 0) {
|
||||
const negativeInterval = interval.negate();
|
||||
return `-${negativeInterval.toFormat("hh:mm:ss.SSS")}`;
|
||||
}
|
||||
return interval.toFormat("hh:mm:ss.SSS");
|
||||
}
|
||||
|
||||
function dateTimeFromWubloaderTime(wubloaderTime) {
|
||||
return DateTime.fromISO(wubloaderTime);
|
||||
}
|
||||
|
||||
function wubloaderTimeFromDateTime(dateTime) {
|
||||
if (!dateTime) {
|
||||
return null;
|
||||
}
|
||||
// Not using ISO here because Luxon doesn't give us a quick way to print an ISO8601 string with no offset.
|
||||
return dateTime.toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
|
||||
}
|
||||
|
||||
function busTimeFromWubloaderTime(wubloaderTime) {
|
||||
if (wubloaderTime === "") {
|
||||
return "";
|
||||
}
|
||||
const dt = dateTimeFromWubloaderTime(wubloaderTime);
|
||||
return busTimeFromDateTime(dt);
|
||||
}
|
||||
|
||||
function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) {
|
||||
const hours = Math.floor(videoPlayerTime / 3600);
|
||||
let minutes = Math.floor((videoPlayerTime % 3600) / 60);
|
||||
let seconds = Math.floor(videoPlayerTime % 60);
|
||||
let milliseconds = Math.floor((videoPlayerTime * 1000) % 1000);
|
||||
|
||||
while (minutes.toString().length < 2) {
|
||||
minutes = `0${minutes}`;
|
||||
}
|
||||
while (seconds.toString().length < 2) {
|
||||
seconds = `0${seconds}`;
|
||||
}
|
||||
while (milliseconds.toString().length < 3) {
|
||||
milliseconds = `0${milliseconds}`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
return `${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
function videoPlayerTimeFromVideoHumanTime(videoHumanTime) {
|
||||
let timeParts = videoHumanTime.split(":", 3);
|
||||
let hours;
|
||||
let minutes;
|
||||
let seconds;
|
||||
|
||||
if (timeParts.length < 2) {
|
||||
hours = 0;
|
||||
minutes = 0;
|
||||
seconds = +timeParts[0];
|
||||
} else if (timeParts.length < 3) {
|
||||
hours = 0;
|
||||
minutes = parseInt(timeParts[0]);
|
||||
seconds = +timeParts[1];
|
||||
} else {
|
||||
hours = parseInt(timeParts[0]);
|
||||
minutes = parseInt(timeParts[1]);
|
||||
seconds = +timeParts[2];
|
||||
}
|
||||
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
function dateTimeFromVideoPlayerTime(videoPlayerTime) {
|
||||
const segmentList = getSegmentList();
|
||||
let segmentStartTime;
|
||||
let segmentStartISOTime;
|
||||
for (const segment of segmentList) {
|
||||
const segmentEndTime = segment.start + segment.duration;
|
||||
if (videoPlayerTime >= segment.start && videoPlayerTime < segmentEndTime) {
|
||||
segmentStartTime = segment.start;
|
||||
segmentStartISOTime = segment.rawProgramDateTime;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (segmentStartISOTime === undefined) {
|
||||
return null;
|
||||
}
|
||||
const wubloaderDateTime = DateTime.fromISO(segmentStartISOTime);
|
||||
const offset = videoPlayerTime - segmentStartTime;
|
||||
return wubloaderDateTime.plus({ seconds: offset });
|
||||
}
|
||||
|
||||
function videoPlayerTimeFromDateTime(dateTime) {
|
||||
const segmentTimes = getSegmentTimes();
|
||||
for (const segmentData of segmentTimes) {
|
||||
const segmentStart = segmentData.rawStart;
|
||||
const segmentEnd = segmentData.rawEnd;
|
||||
if (dateTime >= segmentStart && dateTime <= segmentEnd) {
|
||||
return segmentData.playerStart + dateTime.diff(segmentStart).as("seconds");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function videoHumanTimeFromDateTime(dateTime) {
|
||||
const videoPlayerTime = videoPlayerTimeFromDateTime(dateTime);
|
||||
if (videoPlayerTime === null) {
|
||||
return null;
|
||||
}
|
||||
return videoHumanTimeFromVideoPlayerTime(videoPlayerTime);
|
||||
}
|
||||
|
||||
function assembleVideoPlaylistURL(basePlaylistURL) {
|
||||
let playlistURL = basePlaylistURL;
|
||||
|
||||
const query = startAndEndTimeQuery();
|
||||
if (query.toString() !== "") {
|
||||
playlistURL += "?" + query.toString();
|
||||
}
|
||||
return playlistURL;
|
||||
}
|
||||
|
||||
function startAndEndTimeQuery() {
|
||||
const startTime = getStartTime();
|
||||
const endTime = getEndTime();
|
||||
|
||||
const query = new URLSearchParams();
|
||||
if (startTime) {
|
||||
query.append("start", wubloaderTimeFromDateTime(startTime));
|
||||
}
|
||||
if (endTime) {
|
||||
query.append("end", wubloaderTimeFromDateTime(endTime));
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
function getSegmentList() {
|
||||
return globalPlayer.latencyController.levelDetails.fragments;
|
||||
}
|
||||
|
||||
function hasSegmentList() {
|
||||
if (
|
||||
globalPlayer &&
|
||||
globalPlayer.latencyController &&
|
||||
globalPlayer.latencyController.levelDetails &&
|
||||
globalPlayer.latencyController.levelDetails.fragments
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSegmentTimes() {
|
||||
const segmentList = getSegmentList();
|
||||
const segmentTimes = [];
|
||||
for (const segment of segmentList) {
|
||||
const segmentStart = DateTime.fromISO(segment.rawProgramDateTime);
|
||||
const segmentEnd = segmentStart.plus({ seconds: segment.duration });
|
||||
segmentTimes.push({ rawStart: segmentStart, rawEnd: segmentEnd, playerStart: segment.start });
|
||||
}
|
||||
return segmentTimes;
|
||||
}
|
||||
|
||||
function downloadFrame() {
|
||||
const videoElement = document.getElementById("video");
|
||||
const dateTime = dateTimeFromVideoPlayerTime(videoElement.currentTime);
|
||||
const url = `/frame/${globalStreamName}/source.png?timestamp=${wubloaderTimeFromDateTime(
|
||||
dateTime,
|
||||
)}`;
|
||||
// Avoid : as it causes problems on Windows
|
||||
const filename = `${dateTime.toFormat("yyyy-LL-dd'T'HH-mm-ss.SSS")}.png`;
|
||||
triggerDownload(url, filename);
|
||||
}
|
||||
|
||||
function triggerDownload(url, filename) {
|
||||
// URL must be same-origin.
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("download", filename);
|
||||
link.href = url;
|
||||
link.setAttribute("target", "_blank");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function sendChatLogLoadData() {
|
||||
let startTime = getStartTime();
|
||||
let endTime = getEndTime();
|
||||
if (!startTime || !endTime) {
|
||||
return;
|
||||
}
|
||||
startTime = wubloaderTimeFromDateTime(startTime);
|
||||
endTime = wubloaderTimeFromDateTime(endTime);
|
||||
const segmentMetadata = getSegmentTimes();
|
||||
for (const segmentData of segmentMetadata) {
|
||||
segmentData.rawStart = segmentData.rawStart.toMillis();
|
||||
segmentData.rawEnd = segmentData.rawEnd.toMillis();
|
||||
}
|
||||
|
||||
const message = {
|
||||
stream: globalStreamName,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
segmentMetadata: segmentMetadata,
|
||||
};
|
||||
globalLoadChatWorker.postMessage(message);
|
||||
}
|
||||
|
||||
function updateChatDataFromWorkerResponse(chatData) {
|
||||
for (const chatLine of chatData) {
|
||||
chatLine.when = DateTime.fromMillis(chatLine.when);
|
||||
}
|
||||
globalChatData = chatData;
|
||||
}
|
||||
|
||||
function renderChatMessage(chatMessageData) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
if (chatMessage.command !== "PRIVMSG" || chatMessage.params[0] !== `#${globalStreamName}`) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sendTimeElement = document.createElement("div");
|
||||
sendTimeElement.classList.add("chat-replay-message-time");
|
||||
sendTimeElement.innerText = chatMessageData.displayWhen;
|
||||
|
||||
const senderNameElement = createMessageSenderElement(chatMessageData);
|
||||
|
||||
const messageTextElement = document.createElement("div");
|
||||
messageTextElement.classList.add("chat-replay-message-text");
|
||||
|
||||
if (chatMessage.tags.hasOwnProperty("reply-parent-msg-id")) {
|
||||
const replyParentID = chatMessage.tags["reply-parent-msg-id"];
|
||||
const replyParentSender = chatMessage.tags["reply-parent-display-name"];
|
||||
let replyParentMessageText = chatMessage.tags["reply-parent-msg-body"];
|
||||
const replyContainer = document.createElement("div");
|
||||
const replyTextContainer = document.createElement("a");
|
||||
|
||||
if (replyParentMessageText.startsWith("\u0001ACTION")) {
|
||||
replyContainer.classList.add("chat-replay-message-text-action");
|
||||
const substringEnd = replyParentMessageText.endsWith("\u0001")
|
||||
? replyParentMessageText.length - 1
|
||||
: replyParentMessageText;
|
||||
replyParentMessageText = replyParentMessageText.substring(7, substringEnd);
|
||||
}
|
||||
|
||||
replyTextContainer.href = `#chat-replay-message-${replyParentID}`;
|
||||
replyTextContainer.innerText = `Replying to ${replyParentSender}: ${replyParentMessageText}`;
|
||||
replyContainer.appendChild(replyTextContainer);
|
||||
replyContainer.classList.add("chat-replay-message-reply");
|
||||
messageTextElement.appendChild(replyContainer);
|
||||
}
|
||||
|
||||
addChatMessageTextToElement(chatMessageData, messageTextElement);
|
||||
|
||||
const messageContainer = createMessageContainer(chatMessageData, false);
|
||||
messageContainer.appendChild(sendTimeElement);
|
||||
messageContainer.appendChild(senderNameElement);
|
||||
messageContainer.appendChild(messageTextElement);
|
||||
return messageContainer;
|
||||
}
|
||||
|
||||
function renderSystemMessages(chatMessageData) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
if (chatMessage.command !== "USERNOTICE" || chatMessage.params[0] != `#${globalStreamName}`) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
|
||||
const sendTimeElement = document.createElement("div");
|
||||
sendTimeElement.classList.add("chat-replay-message-time");
|
||||
sendTimeElement.innerText = chatMessageData.displayWhen;
|
||||
|
||||
const systemTextElement = document.createElement("div");
|
||||
systemTextElement.classList.add("chat-replay-message-text");
|
||||
systemTextElement.classList.add("chat-replay-message-system");
|
||||
let systemMsg = chatMessage.tags["system-msg"];
|
||||
if (!systemMsg && chatMessage.tags["msg-id"] === "announcement") {
|
||||
systemMsg = "Announcement";
|
||||
}
|
||||
systemTextElement.appendChild(document.createTextNode(systemMsg));
|
||||
|
||||
const firstMessageContainer = createMessageContainer(chatMessageData, true);
|
||||
firstMessageContainer.appendChild(sendTimeElement);
|
||||
firstMessageContainer.appendChild(systemTextElement);
|
||||
messages.push(firstMessageContainer);
|
||||
|
||||
if (chatMessage.params.length === 1) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const emptySendTimeElement = document.createElement("div");
|
||||
emptySendTimeElement.classList.add("chat-replay-message-time");
|
||||
|
||||
const senderNameElement = createMessageSenderElement(chatMessageData);
|
||||
|
||||
const messageTextElement = document.createElement("div");
|
||||
messageTextElement.classList.add("chat-replay-message-text");
|
||||
addChatMessageTextToElement(chatMessageData, messageTextElement);
|
||||
|
||||
const secondMessageContainer = createMessageContainer(chatMessageData, false);
|
||||
secondMessageContainer.appendChild(emptySendTimeElement);
|
||||
secondMessageContainer.appendChild(senderNameElement);
|
||||
secondMessageContainer.appendChild(messageTextElement);
|
||||
messages.push(secondMessageContainer);
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function createMessageContainer(chatMessageData, isSystemMessage) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
const messageContainer = document.createElement("div");
|
||||
messageContainer.classList.add("chat-replay-message");
|
||||
if (chatMessage.tags.hasOwnProperty("id")) {
|
||||
if (isSystemMessage) {
|
||||
messageContainer.id = `chat-replay-message-system-${chatMessage.tags.id}`;
|
||||
} else {
|
||||
messageContainer.id = `chat-replay-message-${chatMessage.tags.id}`;
|
||||
}
|
||||
}
|
||||
messageContainer.dataset.sender = chatMessage.sender;
|
||||
return messageContainer;
|
||||
}
|
||||
|
||||
function getMessageDisplayName(chatMessageData) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
if (chatMessage.tags.hasOwnProperty("display-name")) {
|
||||
return chatMessage.tags["display-name"];
|
||||
}
|
||||
return chatMessage.sender;
|
||||
}
|
||||
|
||||
function createMessageSenderElement(chatMessageData) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
const senderNameElement = document.createElement("div");
|
||||
senderNameElement.classList.add("chat-replay-message-sender");
|
||||
if (chatMessage.tags.hasOwnProperty("color")) {
|
||||
senderNameElement.style.color = chatMessage.tags.color;
|
||||
}
|
||||
senderNameElement.innerText = getMessageDisplayName(chatMessageData);
|
||||
return senderNameElement;
|
||||
}
|
||||
|
||||
function addChatMessageTextToElement(chatMessageData, messageTextElement) {
|
||||
const chatMessage = chatMessageData.message;
|
||||
|
||||
let chatMessageText = chatMessage.params[1];
|
||||
if (chatMessageText.startsWith("\u0001ACTION")) {
|
||||
messageTextElement.classList.add("chat-replay-message-text-action");
|
||||
const substringEnd = chatMessageText.endsWith("\u0001")
|
||||
? chatMessageText.length - 1
|
||||
: chatMessageText.length;
|
||||
chatMessageText = chatMessageText.substring(7, substringEnd);
|
||||
}
|
||||
|
||||
if (chatMessage.tags.emotes) {
|
||||
const emoteDataStrings = chatMessage.tags.emotes.split("/");
|
||||
let emotePositions = [];
|
||||
for (const emoteDataString of emoteDataStrings) {
|
||||
const emoteData = emoteDataString.split(":", 2);
|
||||
const emoteID = emoteData[0];
|
||||
const emotePositionList = emoteData[1].split(",").map((val) => {
|
||||
const positions = val.split("-");
|
||||
return { emote: emoteID, start: +positions[0], end: +positions[1] };
|
||||
});
|
||||
emotePositions = emotePositions.concat(emotePositionList);
|
||||
}
|
||||
emotePositions.sort((a, b) => a.start - b.start);
|
||||
|
||||
let messageText = [chatMessageText];
|
||||
while (emotePositions.length > 0) {
|
||||
const emoteData = emotePositions.pop(); // Pop the highest-index element from the array
|
||||
let text = messageText.shift();
|
||||
const textAndEmote = [text.substring(0, emoteData.start)];
|
||||
|
||||
const emoteImg = document.createElement("img");
|
||||
emoteImg.src = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteData.emote}/default/dark/1.0`;
|
||||
const emoteText = text.substring(emoteData.start, emoteData.end + 1);
|
||||
emoteImg.alt = emoteText;
|
||||
emoteImg.title = emoteText;
|
||||
const emoteContainer = document.createElement("span");
|
||||
emoteContainer.classList.add("chat-replay-message-emote");
|
||||
emoteContainer.appendChild(emoteImg);
|
||||
textAndEmote.push(emoteContainer);
|
||||
|
||||
const remainingText = text.substring(emoteData.end + 1);
|
||||
if (remainingText !== "") {
|
||||
textAndEmote.push(remainingText);
|
||||
}
|
||||
messageText = textAndEmote.concat(messageText);
|
||||
}
|
||||
|
||||
for (const messagePart of messageText) {
|
||||
if (typeof messagePart === "string") {
|
||||
const node = document.createTextNode(messagePart);
|
||||
messageTextElement.appendChild(node);
|
||||
} else {
|
||||
messageTextElement.appendChild(messagePart);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messageTextElement.appendChild(document.createTextNode(chatMessageText));
|
||||
}
|
||||
}
|
@ -1,163 +0,0 @@
|
||||
function moveSpeed(amount) {
|
||||
const videoElement = document.getElementById("video");
|
||||
let currentIndex = PLAYBACK_RATES.indexOf(videoElement.playbackRate);
|
||||
if (currentIndex === -1) {
|
||||
addError("The playback rate has somehow gone very wrong.");
|
||||
return;
|
||||
}
|
||||
currentIndex += amount;
|
||||
if (currentIndex < 0 || currentIndex >= PLAYBACK_RATES.length) {
|
||||
return; // We've reached/exceeded the edge
|
||||
}
|
||||
setSpeed(videoElement, PLAYBACK_RATES[currentIndex]);
|
||||
}
|
||||
|
||||
function increaseSpeed() {
|
||||
moveSpeed(1);
|
||||
}
|
||||
|
||||
function decreaseSpeed() {
|
||||
moveSpeed(-1);
|
||||
}
|
||||
|
||||
function setSpeed(videoElement, speed) {
|
||||
videoElement.playbackRate = speed;
|
||||
const playbackSelector = document.getElementById("video-controls-playback-speed");
|
||||
playbackSelector.value = speed;
|
||||
}
|
||||
|
||||
document.addEventListener("keypress", (event) => {
|
||||
if (event.target.nodeName === "INPUT" || event.target.nodeName === "TEXTAREA") {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoElement = document.getElementById("video");
|
||||
switch (event.key) {
|
||||
case "0":
|
||||
videoElement.currentTime = 0;
|
||||
break;
|
||||
case "1":
|
||||
videoElement.currentTime = videoElement.duration * 0.1;
|
||||
break;
|
||||
case "2":
|
||||
videoElement.currentTime = videoElement.duration * 0.2;
|
||||
break;
|
||||
case "3":
|
||||
videoElement.currentTime = videoElement.duration * 0.3;
|
||||
break;
|
||||
case "4":
|
||||
videoElement.currentTime = videoElement.duration * 0.4;
|
||||
break;
|
||||
case "5":
|
||||
videoElement.currentTime = videoElement.duration * 0.5;
|
||||
break;
|
||||
case "6":
|
||||
videoElement.currentTime = videoElement.duration * 0.6;
|
||||
break;
|
||||
case "7":
|
||||
videoElement.currentTime = videoElement.duration * 0.7;
|
||||
break;
|
||||
case "8":
|
||||
videoElement.currentTime = videoElement.duration * 0.8;
|
||||
break;
|
||||
case "9":
|
||||
videoElement.currentTime = videoElement.duration * 0.9;
|
||||
break;
|
||||
case "j":
|
||||
videoElement.currentTime -= 10;
|
||||
break;
|
||||
case "k":
|
||||
case "K":
|
||||
case " ":
|
||||
if (videoElement.paused) {
|
||||
videoElement.play();
|
||||
} else {
|
||||
videoElement.pause();
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "l":
|
||||
videoElement.currentTime += 10;
|
||||
break;
|
||||
case "J":
|
||||
videoElement.currentTime -= 1;
|
||||
break;
|
||||
case "L":
|
||||
videoElement.currentTime += 1;
|
||||
break;
|
||||
case "m":
|
||||
videoElement.muted = !videoElement.muted;
|
||||
break;
|
||||
case ",":
|
||||
case "<":
|
||||
videoElement.currentTime -= 1 / VIDEO_FRAMES_PER_SECOND;
|
||||
break;
|
||||
case ".":
|
||||
case ">":
|
||||
videoElement.currentTime += 1 / VIDEO_FRAMES_PER_SECOND;
|
||||
break;
|
||||
case "=":
|
||||
increaseSpeed();
|
||||
break;
|
||||
case "+":
|
||||
const playbackRate = videoElement.playbackRate;
|
||||
if (playbackRate < 2) {
|
||||
setSpeed(videoElement, 2);
|
||||
} else {
|
||||
setSpeed(videoElement, PLAYBACK_RATES[PLAYBACK_RATES.length - 1]);
|
||||
}
|
||||
break;
|
||||
case "-":
|
||||
decreaseSpeed();
|
||||
break;
|
||||
case "_":
|
||||
setSpeed(videoElement, PLAYBACK_RATES[0]);
|
||||
break;
|
||||
case "[":
|
||||
if (typeof setCurrentRangeStartToVideoTime === "function") {
|
||||
setCurrentRangeStartToVideoTime();
|
||||
}
|
||||
break;
|
||||
case "]":
|
||||
if (typeof setCurrentRangeEndToVideoTime === "function") {
|
||||
setCurrentRangeEndToVideoTime();
|
||||
}
|
||||
break;
|
||||
case "o":
|
||||
if (typeof moveToPreviousRange === "function") {
|
||||
moveToPreviousRange();
|
||||
}
|
||||
break;
|
||||
case "p":
|
||||
if (typeof moveToNextRange === "function") {
|
||||
moveToNextRange();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// For whatever reason, arrow keys don't work for keypress. We can use keydown for them.
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.target.nodeName === "INPUT" || event.target.nodeName === "TEXTAREA") {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoElement = document.getElementById("video");
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
videoElement.currentTime -= 5;
|
||||
break;
|
||||
case "ArrowRight":
|
||||
videoElement.currentTime += 5;
|
||||
break;
|
||||
case "Backspace":
|
||||
event.preventDefault();
|
||||
videoElement.playbackRate = 1;
|
||||
document.getElementById("video-controls-playback-speed").value = 1;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
@ -1,345 +0,0 @@
|
||||
const TIME_FRAME_UTC = 1;
|
||||
const TIME_FRAME_BUS = 2;
|
||||
const TIME_FRAME_AGO = 3;
|
||||
|
||||
var globalLoadedVideoPlayer = false;
|
||||
var globalVideoTimeReference = TIME_FRAME_AGO;
|
||||
var globalChatPreviousRenderTime = null;
|
||||
|
||||
window.addEventListener("DOMContentLoaded", async (event) => {
|
||||
commonPageSetup();
|
||||
globalLoadChatWorker.onmessage = (event) => {
|
||||
updateChatDataFromWorkerResponse(event.data);
|
||||
initialChatRender();
|
||||
};
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
if (queryParams.has("start")) {
|
||||
document.getElementById("stream-time-frame-of-reference-utc").checked = true;
|
||||
document.getElementById("stream-time-setting-start").value = queryParams.get("start");
|
||||
if (queryParams.has("end")) {
|
||||
document.getElementById("stream-time-setting-end").value = queryParams.get("end");
|
||||
}
|
||||
}
|
||||
if (queryParams.has("stream")) {
|
||||
document.getElementById("stream-time-setting-stream").value = queryParams.get("stream");
|
||||
}
|
||||
|
||||
await loadDefaults();
|
||||
|
||||
const timeSettingsForm = document.getElementById("stream-time-settings");
|
||||
timeSettingsForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
updateTimeSettings();
|
||||
});
|
||||
|
||||
document.getElementById("download-frame").addEventListener("click", (_event) => {
|
||||
downloadFrame();
|
||||
});
|
||||
|
||||
const timeConversionForm = document.getElementById("time-converter");
|
||||
timeConversionForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
convertEnteredTimes();
|
||||
});
|
||||
|
||||
const timeConversionLink = document.getElementById("time-converter-link");
|
||||
timeConversionLink.addEventListener("click", (_event) => {
|
||||
const timeConversionForm = document.getElementById("time-converter");
|
||||
timeConversionForm.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
const addTimeConversionButton = document.getElementById("time-converter-add-time");
|
||||
addTimeConversionButton.addEventListener("click", (_event) => {
|
||||
const newField = document.createElement("input");
|
||||
newField.classList.add("time-converter-time");
|
||||
newField.type = "text";
|
||||
newField.placeholder = "Time to convert";
|
||||
const container = document.getElementById("time-converter-time-container");
|
||||
container.appendChild(newField);
|
||||
});
|
||||
|
||||
await updateTimeSettings();
|
||||
|
||||
const videoPlayer = document.getElementById("video");
|
||||
videoPlayer.addEventListener("loadedmetadata", (_event) => initialChatRender());
|
||||
videoPlayer.addEventListener("timeupdate", (_event) => updateChatRender());
|
||||
});
|
||||
|
||||
async function loadDefaults() {
|
||||
const defaultDataResponse = await fetch("/thrimshim/defaults");
|
||||
if (!defaultDataResponse.ok) {
|
||||
addError(
|
||||
"Failed to load Thrimbletrimmer data. This probably means that everything is broken (or, possibly, just that the Wubloader host is down). Please sound the alarm.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const defaultData = await defaultDataResponse.json();
|
||||
|
||||
const streamNameField = document.getElementById("stream-time-setting-stream");
|
||||
if (streamNameField.value === "") {
|
||||
streamNameField.value = defaultData.video_channel;
|
||||
}
|
||||
|
||||
globalBusStartTime = DateTime.fromISO(defaultData.bustime_start);
|
||||
}
|
||||
|
||||
// Gets the start time of the video from settings. Returns an invalid date object if the user entered bad data.
|
||||
function getStartTime() {
|
||||
return dateTimeFromTimeString(globalStartTimeString, globalVideoTimeReference);
|
||||
}
|
||||
|
||||
// Gets the end time of the video from settings. Returns null if there's no end time. Returns an invalid date object if the user entered bad data.
|
||||
function getEndTime() {
|
||||
if (globalEndTimeString === "") {
|
||||
return null;
|
||||
}
|
||||
return dateTimeFromTimeString(globalEndTimeString, globalVideoTimeReference);
|
||||
}
|
||||
|
||||
function dateTimeFromTimeString(timeString, timeStringFormat) {
|
||||
switch (timeStringFormat) {
|
||||
case 1:
|
||||
return dateTimeFromWubloaderTime(timeString);
|
||||
case 2:
|
||||
return dateTimeFromBusTime(timeString);
|
||||
case 3:
|
||||
return DateTime.now().setZone("utc").minus(dateTimeMathObjectFromBusTime(timeString));
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTimeSettings() {
|
||||
updateStoredTimeSettings();
|
||||
if (globalLoadedVideoPlayer) {
|
||||
updateSegmentPlaylist();
|
||||
} else {
|
||||
loadVideoPlayerFromDefaultPlaylist();
|
||||
globalLoadedVideoPlayer = true;
|
||||
}
|
||||
|
||||
updateDownloadLink();
|
||||
|
||||
const startTime = getStartTime();
|
||||
const endTime = getEndTime();
|
||||
const query = new URLSearchParams({
|
||||
stream: globalStreamName,
|
||||
start: wubloaderTimeFromDateTime(startTime),
|
||||
});
|
||||
if (endTime) {
|
||||
query.append("end", wubloaderTimeFromDateTime(endTime));
|
||||
}
|
||||
document.getElementById("stream-time-link").href = `?${query}`;
|
||||
}
|
||||
|
||||
function generateDownloadURL(startTime, endTime, downloadType, allowHoles, quality) {
|
||||
const startURLTime = wubloaderTimeFromDateTime(startTime);
|
||||
const endURLTime = wubloaderTimeFromDateTime(endTime);
|
||||
|
||||
const query = new URLSearchParams({
|
||||
type: downloadType,
|
||||
allow_holes: allowHoles,
|
||||
});
|
||||
if (startURLTime) {
|
||||
query.append("start", startURLTime);
|
||||
}
|
||||
if (endURLTime) {
|
||||
query.append("end", endURLTime);
|
||||
}
|
||||
|
||||
const downloadURL = `/cut/${globalStreamName}/${quality}.ts?${query}`;
|
||||
return downloadURL;
|
||||
}
|
||||
|
||||
function updateDownloadLink() {
|
||||
const downloadLink = document.getElementById("download");
|
||||
const downloadURL = generateDownloadURL(getStartTime(), getEndTime(), "smart", true, "source");
|
||||
downloadLink.href = downloadURL;
|
||||
}
|
||||
|
||||
function updateStoredTimeSettings() {
|
||||
globalStreamName = document.getElementById("stream-time-setting-stream").value;
|
||||
globalStartTimeString = document.getElementById("stream-time-setting-start").value;
|
||||
globalEndTimeString = document.getElementById("stream-time-setting-end").value;
|
||||
|
||||
const radioSelection = document.querySelectorAll("#stream-time-frame-of-reference > input");
|
||||
for (radioItem of radioSelection) {
|
||||
if (radioItem.checked) {
|
||||
globalVideoTimeReference = +radioItem.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function convertEnteredTimes() {
|
||||
let timeConvertFrom = undefined;
|
||||
const timeConvertFromSelection = document.querySelectorAll(
|
||||
"#time-converter input[name=time-converter-from]",
|
||||
);
|
||||
for (const convertFromItem of timeConvertFromSelection) {
|
||||
if (convertFromItem.checked) {
|
||||
timeConvertFrom = +convertFromItem.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!timeConvertFrom) {
|
||||
addError("Failed to convert times - input format not specified");
|
||||
return;
|
||||
}
|
||||
|
||||
let timeConvertTo = undefined;
|
||||
const timeConvertToSelection = document.querySelectorAll(
|
||||
"#time-converter input[name=time-converter-to]",
|
||||
);
|
||||
for (const convertToItem of timeConvertToSelection) {
|
||||
if (convertToItem.checked) {
|
||||
timeConvertTo = +convertToItem.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!timeConvertTo) {
|
||||
addError("Failed to convert times - output format not specified");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeFieldList = document.getElementsByClassName("time-converter-time");
|
||||
const now = DateTime.now().setZone("utc");
|
||||
for (const timeField of timeFieldList) {
|
||||
const enteredTime = timeField.value;
|
||||
if (enteredTime === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let time = dateTimeFromTimeString(enteredTime, timeConvertFrom);
|
||||
if (!time) {
|
||||
addError(
|
||||
`Failed to parse the time '${enteredTime}' as a value of the selected "convert from" time format.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (timeConvertTo === TIME_FRAME_UTC) {
|
||||
timeField.value = wubloaderTimeFromDateTime(time);
|
||||
} else if (timeConvertTo === TIME_FRAME_BUS) {
|
||||
timeField.value = busTimeFromDateTime(time);
|
||||
} else if (timeConvertTo === TIME_FRAME_AGO) {
|
||||
const difference = now.diff(time);
|
||||
timeField.value = formatIntervalForDisplay(difference);
|
||||
}
|
||||
}
|
||||
|
||||
if (timeConvertTo === TIME_FRAME_UTC) {
|
||||
document.getElementById("time-converter-from-utc").checked = true;
|
||||
} else if (timeConvertTo === TIME_FRAME_BUS) {
|
||||
document.getElementById("time-converter-from-bus").checked = true;
|
||||
} else if (timeConvertTo === TIME_FRAME_AGO) {
|
||||
document.getElementById("time-converter-from-ago").checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function initialChatRender() {
|
||||
if (!globalChatData || globalChatData.length === 0) {
|
||||
return;
|
||||
}
|
||||
const videoPlayer = document.getElementById("video");
|
||||
const videoTime = videoPlayer.currentTime;
|
||||
const videoDateTime = dateTimeFromVideoPlayerTime(videoTime);
|
||||
const chatReplayContainer = document.getElementById("chat-replay");
|
||||
chatReplayContainer.innerHTML = "";
|
||||
|
||||
for (const chatMessage of globalChatData) {
|
||||
if (chatMessage.when > videoDateTime) {
|
||||
break;
|
||||
}
|
||||
handleChatMessage(chatReplayContainer, chatMessage);
|
||||
}
|
||||
|
||||
globalChatPreviousRenderTime = videoTime;
|
||||
chatReplayContainer.scrollTop = chatReplayContainer.scrollHeight;
|
||||
}
|
||||
|
||||
function updateChatRender() {
|
||||
if (!globalChatData || globalChatData.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!hasSegmentList()) {
|
||||
// The update is due to a stream refresh, so we'll wait for the initial render instead
|
||||
return;
|
||||
}
|
||||
const videoPlayer = document.getElementById("video");
|
||||
const videoTime = videoPlayer.currentTime;
|
||||
const chatReplayContainer = document.getElementById("chat-replay");
|
||||
const wasScrolledToBottom =
|
||||
chatReplayContainer.scrollTop + chatReplayContainer.offsetHeight >=
|
||||
chatReplayContainer.scrollHeight;
|
||||
|
||||
if (videoTime < globalChatPreviousRenderTime) {
|
||||
initialChatRender();
|
||||
} else {
|
||||
const videoDateTime = dateTimeFromVideoPlayerTime(videoTime);
|
||||
const lastAddedTime = dateTimeFromVideoPlayerTime(globalChatPreviousRenderTime);
|
||||
|
||||
let rangeMin = 0;
|
||||
let rangeMax = globalChatData.length;
|
||||
let lastChatIndex = Math.floor((rangeMin + rangeMax) / 2);
|
||||
while (rangeMax - rangeMin > 1) {
|
||||
if (globalChatData[lastChatIndex].when === lastAddedTime) {
|
||||
break;
|
||||
}
|
||||
if (globalChatData[lastChatIndex].when < lastAddedTime) {
|
||||
rangeMin = lastChatIndex;
|
||||
} else {
|
||||
rangeMax = lastChatIndex;
|
||||
}
|
||||
lastChatIndex = Math.floor((rangeMin + rangeMax) / 2);
|
||||
}
|
||||
|
||||
if (lastChatIndex === 0 && globalChatData[0].when > lastAddedTime) {
|
||||
lastChatIndex = -1;
|
||||
}
|
||||
|
||||
for (let chatIndex = lastChatIndex + 1; chatIndex < globalChatData.length; chatIndex++) {
|
||||
const chatMessage = globalChatData[chatIndex];
|
||||
if (chatMessage.when > videoDateTime) {
|
||||
break;
|
||||
}
|
||||
handleChatMessage(chatReplayContainer, chatMessage);
|
||||
}
|
||||
}
|
||||
globalChatPreviousRenderTime = videoTime;
|
||||
if (wasScrolledToBottom) {
|
||||
chatReplayContainer.scrollTop = chatReplayContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatMessage(chatReplayContainer, chatMessage) {
|
||||
if (chatMessage.message.command === "PRIVMSG") {
|
||||
const chatDOM = renderChatMessage(chatMessage);
|
||||
if (chatDOM) {
|
||||
chatReplayContainer.appendChild(chatDOM);
|
||||
}
|
||||
} else if (chatMessage.message.command === "CLEARMSG") {
|
||||
const removedID = chatMessage.message.tags["target-msg-id"];
|
||||
const targetMessageElem = document.getElementById(`chat-replay-message-${removedID}`);
|
||||
if (targetMessageElem) {
|
||||
targetMessageElem.classList.add("chat-replay-message-cleared");
|
||||
}
|
||||
} else if (chatMessage.message.command === "CLEARCHAT") {
|
||||
if (chatMessage.message.params.length > 1) {
|
||||
const removedSender = chatMessage.message.params[1];
|
||||
for (const messageElem of chatReplayContainer.children) {
|
||||
if (messageElem.dataset.sender === removedSender) {
|
||||
messageElem.classList.add("chat-replay-message-cleared");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const messageElem of chatReplayContainer.children) {
|
||||
messageElem.classList.add("chat-replay-message-cleared");
|
||||
}
|
||||
}
|
||||
} else if (chatMessage.message.command === "USERNOTICE") {
|
||||
const chatDOMList = renderSystemMessages(chatMessage);
|
||||
for (const chatDOM of chatDOMList) {
|
||||
chatReplayContainer.appendChild(chatDOM);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,444 +0,0 @@
|
||||
let viewingTemplate = null;
|
||||
let googleUser = null;
|
||||
let templateData = [];
|
||||
|
||||
function googleOnSignIn(googleUserData) {
|
||||
googleUser = googleUserData;
|
||||
const signInElem = document.getElementById("google-auth-sign-in");
|
||||
const signOutElem = document.getElementById("google-auth-sign-out");
|
||||
signInElem.classList.add("hidden");
|
||||
signOutElem.classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function googleSignOut() {
|
||||
if (googleUser) {
|
||||
googleUser = null;
|
||||
await gapi.auth2.getAuthInstance().signOut();
|
||||
const signInElem = document.getElementById("google-auth-sign-in");
|
||||
const signOutElem = document.getElementById("google-auth-sign-out");
|
||||
signInElem.classList.remove("hidden");
|
||||
signOutElem.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", async (event) => {
|
||||
document.getElementById("template-new-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const errorListContainer = document.getElementById("template-new-errors");
|
||||
errorListContainer.innerHTML = "";
|
||||
|
||||
const form = document.getElementById("template-new-form");
|
||||
const formData = new FormData(form);
|
||||
|
||||
const name = formData.get("name");
|
||||
|
||||
const imageFile = formData.get("image");
|
||||
const fileReader = new FileReader();
|
||||
const fileReaderCompletePromise = new Promise((resolve, reject) => {
|
||||
fileReader.addEventListener("loadend", (event) => resolve());
|
||||
});
|
||||
fileReader.readAsDataURL(imageFile);
|
||||
|
||||
const description = formData.get("description");
|
||||
const attribution = formData.get("attribution");
|
||||
|
||||
const cropXStart = parseInt(formData.get("cropxstart"), 10);
|
||||
const cropYStart = parseInt(formData.get("cropystart"), 10);
|
||||
const cropXEnd = parseInt(formData.get("cropxend"), 10);
|
||||
const cropYEnd = parseInt(formData.get("cropyend"), 10);
|
||||
|
||||
const locXStart = parseInt(formData.get("locxstart"), 10);
|
||||
const locYStart = parseInt(formData.get("locystart"), 10);
|
||||
const locXEnd = parseInt(formData.get("locxend"), 10);
|
||||
const locYEnd = parseInt(formData.get("locyend"), 10);
|
||||
|
||||
if (
|
||||
isNaN(cropXStart) ||
|
||||
isNaN(cropYStart) ||
|
||||
isNaN(cropXEnd) ||
|
||||
isNaN(cropYEnd) ||
|
||||
isNaN(locXStart) ||
|
||||
isNaN(locYStart) ||
|
||||
isNaN(locXEnd) ||
|
||||
isNaN(locYEnd)
|
||||
) {
|
||||
const parseNumbersError = document.createElement("li");
|
||||
parseNumbersError.innerText = "All crop and location information must be entered";
|
||||
errorListContainer.appendChild(parseNumbersError);
|
||||
}
|
||||
|
||||
await fileReaderCompletePromise;
|
||||
|
||||
const imageDataURL = fileReader.result;
|
||||
if (!imageDataURL.startsWith("data:image/png;base64,")) {
|
||||
const imageReadError = document.createElement("li");
|
||||
imageReadError.innerText = "Couldn't read the image data, or the image wasn't a valid PNG";
|
||||
errorListContainer.appendChild(imageReadError);
|
||||
return;
|
||||
}
|
||||
const image = imageDataURL.substring(22);
|
||||
|
||||
const submitData = {
|
||||
name: name,
|
||||
image: image,
|
||||
description: description,
|
||||
attribution: attribution,
|
||||
crop: [cropXStart, cropYStart, cropXEnd, cropYEnd],
|
||||
location: [locXStart, locYStart, locXEnd, locYEnd],
|
||||
};
|
||||
if (googleUser) {
|
||||
submitData.token = googleUser.getAuthResponse().id_token;
|
||||
}
|
||||
|
||||
if (!errorListContainer.hasChildNodes()) {
|
||||
const submitResponse = await fetch("/thrimshim/add-template", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(submitData),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!submitResponse.ok) {
|
||||
const submitError = document.createElement("li");
|
||||
submitError.innerText = await submitResponse.text();
|
||||
errorListContainer.appendChild(submitError);
|
||||
return;
|
||||
}
|
||||
|
||||
addTemplate(templateData.length, submitData);
|
||||
templateData.push(submitData);
|
||||
|
||||
form.reset();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("google-auth-sign-out").addEventListener("click", (_event) => {
|
||||
googleSignOut();
|
||||
});
|
||||
|
||||
const templateDataResponse = await fetch("/thrimshim/templates");
|
||||
if (!templateDataResponse.ok) {
|
||||
return;
|
||||
}
|
||||
templateData = await templateDataResponse.json();
|
||||
|
||||
for (const [index, template] of templateData.entries()) {
|
||||
addTemplate(index, template);
|
||||
}
|
||||
});
|
||||
|
||||
function generateTemplateDOM(index, template) {
|
||||
const { name, description, attribution, crop, location } = template;
|
||||
|
||||
const editForm = document.createElement("form");
|
||||
editForm.id = `template-data-edit-form-${index}`;
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
const nameReadCell = document.createElement("div");
|
||||
nameReadCell.classList.add("template-data-view");
|
||||
nameReadCell.innerText = name;
|
||||
const nameEditCell = document.createElement("div");
|
||||
nameEditCell.classList.add("template-data-edit", "hidden");
|
||||
const nameEditField = document.createElement("input");
|
||||
nameEditField.type = "text";
|
||||
nameEditField.name = "name";
|
||||
nameEditField.value = name;
|
||||
nameEditField.form = editForm.id;
|
||||
nameEditCell.appendChild(nameEditField);
|
||||
nameCell.appendChild(nameReadCell);
|
||||
nameCell.appendChild(nameEditCell);
|
||||
|
||||
const descriptionCell = document.createElement("td");
|
||||
const descriptionReadCell = document.createElement("div");
|
||||
descriptionReadCell.classList.add("template-data-view");
|
||||
descriptionReadCell.innerText = description;
|
||||
const descriptionEditCell = document.createElement("div");
|
||||
descriptionEditCell.classList.add("template-data-edit", "hidden");
|
||||
const descriptionEditField = document.createElement("textarea");
|
||||
descriptionEditField.name = "description";
|
||||
descriptionEditField.value = description;
|
||||
descriptionEditField.form = editForm.id;
|
||||
descriptionEditCell.appendChild(descriptionEditField);
|
||||
descriptionCell.appendChild(descriptionReadCell);
|
||||
descriptionCell.appendChild(descriptionEditCell);
|
||||
|
||||
const attributionCell = document.createElement("td");
|
||||
const attributionReadCell = document.createElement("div");
|
||||
attributionReadCell.classList.add("template-data-view");
|
||||
attributionReadCell.innerText = attribution;
|
||||
const attributionEditCell = document.createElement("div");
|
||||
attributionEditCell.classList.add("template-data-edit", "hidden");
|
||||
const attributionEditField = document.createElement("input");
|
||||
attributionEditField.type = "text";
|
||||
attributionEditField.name = "attribution";
|
||||
attributionEditField.value = attribution;
|
||||
attributionEditField.form = editForm.id;
|
||||
attributionEditCell.appendChild(attributionEditField);
|
||||
attributionCell.appendChild(attributionReadCell);
|
||||
attributionCell.appendChild(attributionEditCell);
|
||||
|
||||
const cropCell = document.createElement("td");
|
||||
const cropReadCell = document.createElement("div");
|
||||
cropReadCell.classList.add("template-data-view");
|
||||
cropReadCell.innerText = `(${crop[0]}, ${crop[1]}) to (${crop[2]}, ${crop[3]})`;
|
||||
const cropEditCell = document.createElement("div");
|
||||
cropEditCell.classList.add("template-data-edit", "hidden");
|
||||
|
||||
const cropXStartField = document.createElement("input");
|
||||
cropXStartField.name = "cropxstart";
|
||||
setCoordNumberFieldProps(cropXStartField, "X");
|
||||
cropXStartField.value = crop[0];
|
||||
cropXStartField.form = editForm.id;
|
||||
const cropYStartField = document.createElement("input");
|
||||
cropYStartField.name = "cropystart";
|
||||
setCoordNumberFieldProps(cropYStartField, "Y");
|
||||
cropYStartField.value = crop[1];
|
||||
cropYStartField.form = editForm.id;
|
||||
const cropXEndField = document.createElement("input");
|
||||
cropXEndField.name = "cropxend";
|
||||
setCoordNumberFieldProps(cropXEndField, "X");
|
||||
cropXEndField.value = crop[2];
|
||||
cropXEndField.form = editForm.id;
|
||||
const cropYEndField = document.createElement("input");
|
||||
cropYEndField.name = "cropyend";
|
||||
setCoordNumberFieldProps(cropYEndField, "Y");
|
||||
cropYEndField.value = crop[3];
|
||||
cropYEndField.form = editForm.id;
|
||||
|
||||
cropEditCell.appendChild(document.createTextNode("("));
|
||||
cropEditCell.appendChild(cropXStartField);
|
||||
cropEditCell.appendChild(document.createTextNode(", "));
|
||||
cropEditCell.appendChild(cropYStartField);
|
||||
cropEditCell.appendChild(document.createTextNode(") to ("));
|
||||
cropEditCell.appendChild(cropXEndField);
|
||||
cropEditCell.appendChild(document.createTextNode(", "));
|
||||
cropEditCell.appendChild(cropYEndField);
|
||||
cropEditCell.appendChild(document.createTextNode(")"));
|
||||
|
||||
cropCell.appendChild(cropReadCell);
|
||||
cropCell.appendChild(cropEditCell);
|
||||
|
||||
const locationCell = document.createElement("td");
|
||||
const locationReadCell = document.createElement("div");
|
||||
locationReadCell.classList.add("template-data-view");
|
||||
locationReadCell.innerText = `(${location[0]}, ${location[1]}) to (${location[2]}, ${location[3]})`;
|
||||
const locationEditCell = document.createElement("div");
|
||||
locationEditCell.classList.add("template-data-edit", "hidden");
|
||||
|
||||
const locationXStartField = document.createElement("input");
|
||||
locationXStartField.name = "locxstart";
|
||||
setCoordNumberFieldProps(locationXStartField, "X");
|
||||
locationXStartField.value = location[0];
|
||||
locationXStartField.form = editForm.id;
|
||||
const locationYStartField = document.createElement("input");
|
||||
locationYStartField.name = "locystart";
|
||||
setCoordNumberFieldProps(locationYStartField, "Y");
|
||||
locationYStartField.value = location[1];
|
||||
locationYStartField.form = editForm.id;
|
||||
const locationXEndField = document.createElement("input");
|
||||
locationXEndField.name = "locxend";
|
||||
setCoordNumberFieldProps(locationXEndField, "X");
|
||||
locationXEndField.value = location[2];
|
||||
locationXEndField.form = editForm.id;
|
||||
const locationYEndField = document.createElement("input");
|
||||
locationYEndField.name = "locyend";
|
||||
setCoordNumberFieldProps(locationYEndField, "Y");
|
||||
locationYEndField.value = location[3];
|
||||
locationYEndField.form = editForm.id;
|
||||
|
||||
locationEditCell.appendChild(document.createTextNode("("));
|
||||
locationEditCell.appendChild(locationXStartField);
|
||||
locationEditCell.appendChild(document.createTextNode(", "));
|
||||
locationEditCell.appendChild(locationYStartField);
|
||||
locationEditCell.appendChild(document.createTextNode(") to ("));
|
||||
locationEditCell.appendChild(locationXEndField);
|
||||
locationEditCell.appendChild(document.createTextNode(", "));
|
||||
locationEditCell.appendChild(locationYEndField);
|
||||
locationEditCell.appendChild(document.createTextNode(")"));
|
||||
|
||||
locationCell.appendChild(locationReadCell);
|
||||
locationCell.appendChild(locationEditCell);
|
||||
|
||||
const previewCell = document.createElement("td");
|
||||
const previewReadCell = document.createElement("div");
|
||||
previewReadCell.id = `template-list-preview-${index}`;
|
||||
previewReadCell.classList.add("template-data-view");
|
||||
const previewLink = document.createElement("a");
|
||||
previewLink.href = `javascript:showPreview(${index})`;
|
||||
previewLink.innerText = "Preview";
|
||||
previewReadCell.appendChild(previewLink);
|
||||
const previewEditCell = document.createElement("div");
|
||||
previewEditCell.classList.add("template-data-edit", "hidden");
|
||||
const imageEditField = document.createElement("input");
|
||||
imageEditField.name = "image";
|
||||
imageEditField.type = "file";
|
||||
imageEditField.accept = "image/png";
|
||||
imageEditField.form = editForm.id;
|
||||
previewEditCell.appendChild(imageEditField);
|
||||
previewCell.appendChild(previewReadCell);
|
||||
previewCell.appendChild(previewEditCell);
|
||||
|
||||
const editCell = document.createElement("td");
|
||||
const editReadCell = document.createElement("div");
|
||||
editReadCell.classList.add("template-data-view");
|
||||
const switchToEditButton = document.createElement("button");
|
||||
switchToEditButton.type = "button";
|
||||
switchToEditButton.innerText = "Edit";
|
||||
editReadCell.appendChild(switchToEditButton);
|
||||
const editEditCell = document.createElement("div");
|
||||
editEditCell.classList.add("template-data-edit", "hidden");
|
||||
const editSubmitButton = document.createElement("button");
|
||||
editSubmitButton.type = "submit";
|
||||
editSubmitButton.innerText = "Submit";
|
||||
const editErrors = document.createElement("ul");
|
||||
editErrors.id = `template-data-edit-errors-${index}`;
|
||||
editErrors.classList.add("template-data-edit-errors");
|
||||
editForm.appendChild(editSubmitButton);
|
||||
editForm.appendChild(editErrors);
|
||||
editEditCell.appendChild(editForm);
|
||||
editCell.appendChild(editReadCell);
|
||||
editCell.appendChild(editEditCell);
|
||||
|
||||
const templateRow = document.createElement("tr");
|
||||
templateRow.id = `template-list-data-${index}`;
|
||||
templateRow.appendChild(nameCell);
|
||||
templateRow.appendChild(descriptionCell);
|
||||
templateRow.appendChild(attributionCell);
|
||||
templateRow.appendChild(cropCell);
|
||||
templateRow.appendChild(locationCell);
|
||||
templateRow.appendChild(previewCell);
|
||||
templateRow.appendChild(editCell);
|
||||
|
||||
switchToEditButton.addEventListener("click", (event) => {
|
||||
for (const element of templateRow.getElementsByClassName("template-data-view")) {
|
||||
element.classList.add("hidden");
|
||||
}
|
||||
for (const element of templateRow.getElementsByClassName("template-data-edit")) {
|
||||
element.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
templateRow.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
editErrors.innerHTML = "";
|
||||
|
||||
const name = nameEditField.value;
|
||||
|
||||
const description = descriptionEditField.value;
|
||||
const attribution = attributionEditField.value;
|
||||
|
||||
const cropXStart = parseInt(cropXStartField.value, 10);
|
||||
const cropYStart = parseInt(cropYStartField.value, 10);
|
||||
const cropXEnd = parseInt(cropXEndField.value, 10);
|
||||
const cropYEnd = parseInt(cropYEndField.value, 10);
|
||||
const locXStart = parseInt(locationXStartField.value, 10);
|
||||
const locYStart = parseInt(locationYStartField.value, 10);
|
||||
const locXEnd = parseInt(locationXEndField.value, 10);
|
||||
const locYEnd = parseInt(locationYEndField.value, 10);
|
||||
|
||||
if (
|
||||
isNaN(cropXStart) ||
|
||||
isNaN(cropYStart) ||
|
||||
isNaN(cropXEnd) ||
|
||||
isNaN(cropYEnd) ||
|
||||
isNaN(locXStart) ||
|
||||
isNaN(locXEnd) ||
|
||||
isNaN(locYStart) ||
|
||||
isNaN(locYEnd)
|
||||
) {
|
||||
const parseNumbersError = document.createElement("li");
|
||||
parseNumbersError.innerText = "All crop and location information must be entered";
|
||||
editErrors.appendChild(parseNumbersError);
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
name: name,
|
||||
description: description,
|
||||
attribution: attribution,
|
||||
crop: [cropXStart, cropYStart, cropXEnd, cropYEnd],
|
||||
location: [locXStart, locYStart, locXEnd, locYEnd],
|
||||
};
|
||||
|
||||
const imageFiles = imageEditField.files;
|
||||
if (imageFiles.length > 0) {
|
||||
const fileReader = new FileReader();
|
||||
const fileReaderCompletePromise = new Promise((resolve, reject) => {
|
||||
fileReader.addEventListener("loadend", (event) => resolve());
|
||||
});
|
||||
fileReader.readAsDataURL(imageFiles[0]);
|
||||
await fileReaderCompletePromise;
|
||||
|
||||
const imageDataURL = fileReader.result;
|
||||
if (imageDataURL.startsWith("data:image/png;base64,")) {
|
||||
submitData.image = imageDataURL.substring(22);
|
||||
} else {
|
||||
const imageError = document.createElement("li");
|
||||
imageError.innerText = "Failed to process image as PNG";
|
||||
editErrors.appendChild(imageError);
|
||||
}
|
||||
}
|
||||
if (googleUser) {
|
||||
submitData.token = googleUser.getAuthResponse().id_token;
|
||||
}
|
||||
|
||||
if (editErrors.hasChildNodes()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const origName = templateData[index].name;
|
||||
const encodedName = encodeURIComponent(origName);
|
||||
const submitResponse = await fetch(`/thrimshim/update-template/${encodedName}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(submitData),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!submitResponse.ok) {
|
||||
const submitError = document.createElement("li");
|
||||
submitError.innerText = await submitResponse.text();
|
||||
editErrors.appendChild(submitError);
|
||||
return;
|
||||
}
|
||||
|
||||
templateData[index].name = name;
|
||||
if (submitData.hasOwnProperty("image")) {
|
||||
templateData[index].image = submitData.image;
|
||||
}
|
||||
templateData[index].description = description;
|
||||
templateData[index].attribution = attribution;
|
||||
templateData[index].crop = submitData.crop;
|
||||
templateData[index].location = submitData.location;
|
||||
|
||||
const templateDOM = generateTemplateDOM(index, templateData[index]);
|
||||
templateRow.replaceWith(templateDOM);
|
||||
});
|
||||
|
||||
return templateRow;
|
||||
}
|
||||
|
||||
function addTemplate(index, template) {
|
||||
const templateDOM = generateTemplateDOM(index, template);
|
||||
document.getElementById("template-list-data").appendChild(templateDOM);
|
||||
}
|
||||
|
||||
function setCoordNumberFieldProps(field, direction) {
|
||||
field.type = "number";
|
||||
field.placeholder = direction;
|
||||
field.min = 0;
|
||||
field.step = 1;
|
||||
field.classList.add("template-coord");
|
||||
}
|
||||
|
||||
function showPreview(index) {
|
||||
const template = templateData[index];
|
||||
const previewCell = document.getElementById(`template-list-preview-${index}`);
|
||||
if (!previewCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewContents = document.createElement("img");
|
||||
previewContents.classList.add("template-list-preview");
|
||||
previewContents.src = `/thrimshim/template/${template.name}.png`;
|
||||
|
||||
previewCell.innerHTML = "";
|
||||
previewCell.appendChild(previewContents);
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import Editor from "./editor/Editor";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
render(() => <Editor />, root!);
|
@ -0,0 +1,7 @@
|
||||
import { Component } from "solid-js";
|
||||
|
||||
const Editor: Component = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default Editor;
|
@ -0,0 +1,7 @@
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import Restreamer from "./restreamer/Restreamer";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
render(() => <Restreamer />, root!);
|
@ -0,0 +1,7 @@
|
||||
import { Component } from "solid-js";
|
||||
|
||||
const Restreamer: Component = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default Restreamer;
|
@ -0,0 +1,7 @@
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import ThumbnailManager from "./thumbnails/ThumbnailManager";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
render(() => <ThumbnailManager />, root!);
|
@ -0,0 +1,7 @@
|
||||
import { Component } from "solid-js";
|
||||
|
||||
const ThumbnailManager: Component = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default ThumbnailManager;
|
@ -0,0 +1,54 @@
|
||||
import { Component, createSignal, onCleanup, onMount } from "solid-js";
|
||||
import { DateTime, Interval } from "../external/luxon.min";
|
||||
|
||||
const Clock: Component = () => {
|
||||
const [delay, setDelay] = createSignal<number>(10);
|
||||
const [time, setTime] = createSignal<DateTime>(DateTime.utc());
|
||||
const [busStartTime, setBusStartTime] = createSignal<DateTime | null>(null);
|
||||
|
||||
const timer = setInterval(() => setTime(DateTime.utc()), 250);
|
||||
|
||||
onMount(async () => {
|
||||
const dataResponse = await fetch("/thrimshim/defaults");
|
||||
const data = await dataResponse.json();
|
||||
setBusStartTime(DateTime.fromISO(data.bustime_start));
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
const timeDisplay = () => {
|
||||
const currentTime = time().minus({ seconds: delay() });
|
||||
const busTime = busStartTime();
|
||||
if (!busTime) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [timeElapsed, sign] =
|
||||
currentTime >= busTime
|
||||
? [Interval.fromDateTimes(busTime, currentTime).toDuration("seconds"), ""]
|
||||
: [Interval.fromDateTimes(currentTime, busTime).toDuration("seconds"), "-"];
|
||||
|
||||
const timeElapsedString = timeElapsed.toFormat("h:mm:ss");
|
||||
return `${sign}${timeElapsedString}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{timeDisplay()}</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
value={delay()}
|
||||
min={0}
|
||||
step={1}
|
||||
onInput={(event) => setDelay(+event.currentTarget.value)}
|
||||
/>
|
||||
 seconds of delay
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Clock;
|
@ -0,0 +1,12 @@
|
||||
import { Component } from "solid-js";
|
||||
import Clock from "./Clock";
|
||||
|
||||
const Utilities: Component = () => {
|
||||
return (
|
||||
<>
|
||||
<Clock />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Utilities;
|
@ -0,0 +1,7 @@
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import Utilities from "./utilities/Utilities";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
render(() => <Utilities />, root!);
|
@ -1,173 +0,0 @@
|
||||
.jcrop-widget .jcrop-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
border: 1px rgba(127, 127, 127, 0.8) solid;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8)
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.nw {
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
cursor: nwse-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.w {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: -3px;
|
||||
cursor: ew-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.sw {
|
||||
bottom: -3px;
|
||||
left: -3px;
|
||||
cursor: nesw-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.ne {
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
cursor: nesw-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.e {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: -3px;
|
||||
cursor: ew-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.se {
|
||||
bottom: -3px;
|
||||
right: -3px;
|
||||
cursor: nwse-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.n {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -3px;
|
||||
cursor: ns-resize
|
||||
}
|
||||
|
||||
.jcrop-widget .jcrop-handle.s {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: -3px;
|
||||
cursor: ns-resize
|
||||
}
|
||||
|
||||
.jcrop-widget.active .jcrop-handle {
|
||||
display: block
|
||||
}
|
||||
|
||||
.jcrop-widget {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px white dashed;
|
||||
opacity: 0.7;
|
||||
background: transparent;
|
||||
transition: opacity 1s;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: move
|
||||
}
|
||||
|
||||
.jcrop-widget:hover {
|
||||
transition: opacity 0.8s;
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
.jcrop-shade {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
transition: opacity 0.4s, background-color 0.7s;
|
||||
position: absolute
|
||||
}
|
||||
|
||||
.jcrop-shade.l {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.jcrop-shade.r {
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.jcrop-shade.t {
|
||||
top: 0px
|
||||
}
|
||||
|
||||
.jcrop-shade.b {
|
||||
bottom: 0px
|
||||
}
|
||||
|
||||
.jcrop-stage {
|
||||
position: relative;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.jcrop-image-stage img {
|
||||
position: absolute;
|
||||
z-index: -1
|
||||
}
|
||||
|
||||
.jcrop-ux-inactive-handles .jcrop-widget .jcrop-handle {
|
||||
display: block
|
||||
}
|
||||
|
||||
.jcrop-widget img {
|
||||
width: 100%;
|
||||
height: auto
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget {
|
||||
opacity: 0.25
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget:hover {
|
||||
transition: opacity 0.4s;
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget:focus {
|
||||
transition: opacity 0.5s;
|
||||
opacity: 1;
|
||||
outline-style: auto;
|
||||
outline-width: 3px;
|
||||
outline-color: rgba(0, 0, 0, 0.3)
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget {
|
||||
opacity: 0.25
|
||||
}
|
||||
|
||||
.jcrop-ux-fade-more .jcrop-widget:hover {
|
||||
opacity: 0.65
|
||||
}
|
||||
|
||||
.jcrop-ux-keep-current .jcrop-widget.active {
|
||||
opacity: 1;
|
||||
outline-style: auto;
|
||||
outline-width: 3px;
|
||||
outline-color: rgba(0, 0, 0, 0.3)
|
||||
}
|
||||
|
||||
.jcrop-ux-no-outline .jcrop-widget {
|
||||
outline: none !important
|
||||
}
|
||||
|
||||
.jcrop-disable.jcrop-stage {
|
||||
opacity: .8
|
||||
}
|
||||
|
||||
.jcrop-disable.jcrop-stage .jcrop-widget {
|
||||
outline: none !important
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=jcrop.css.map */
|
@ -1,496 +0,0 @@
|
||||
body {
|
||||
/* Firefox has a weird default font, which is a different size from the one in Chrome
|
||||
* and makes some renderings bad.
|
||||
*/
|
||||
font-family: "Arial", sans-serif;
|
||||
|
||||
background: #222;
|
||||
color: #fff;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ccf;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background: #222;
|
||||
color: #fff;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
textarea {
|
||||
/* This will look better if it's consistent with input fields */
|
||||
border-style: inset;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
a,
|
||||
.click {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.click {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: #b00;
|
||||
}
|
||||
|
||||
.input-error:focus {
|
||||
outline: #d00 solid 1px;
|
||||
}
|
||||
|
||||
#errors {
|
||||
color: #f33;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#errors > div {
|
||||
border-bottom: 1px solid #f33;
|
||||
background: #300;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.error-dismiss {
|
||||
float: right;
|
||||
}
|
||||
|
||||
#page-container {
|
||||
position: relative;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#page-container > * {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
#editor-help {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: #222;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#stream-time-settings {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
#stream-time-settings > div {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#video {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
#video-controls {
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
#video-controls select {
|
||||
appearance: none;
|
||||
font-size: inherit;
|
||||
background: inherit;
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#video-controls option {
|
||||
background: #222;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#video-controls-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#video-controls-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#video-controls-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#video-controls-volume-level {
|
||||
width: 100px;
|
||||
height: 8px;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#video-controls-playback-position {
|
||||
height: 10px;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* For some reason, there's not a cross-browser way to style <progress> elements.
|
||||
* This should be replaced with a cross-browser way of doing this when possible.
|
||||
* I only implemented WebKit/Blink and Firefox here because if you still use IE,
|
||||
* I quite frankly don't care about you.
|
||||
*/
|
||||
|
||||
/* WEBKIT/BLINK SECTION */
|
||||
#video-controls-volume-level::-webkit-progress-bar {
|
||||
background: #ffffff30;
|
||||
}
|
||||
|
||||
#video-controls-volume-level::-webkit-progress-value {
|
||||
background: #ffffffc0;
|
||||
}
|
||||
|
||||
#video-controls-playback-position::-webkit-progress-bar {
|
||||
background: #ffffff30;
|
||||
}
|
||||
|
||||
#video-controls-playback-position::-webkit-progress-value {
|
||||
background: #ffffffc0;
|
||||
}
|
||||
/* END WEBKIT/BLINK */
|
||||
|
||||
/* FIREFOX SECTION */
|
||||
#video-controls-volume-level {
|
||||
background: #ffffff30;
|
||||
}
|
||||
|
||||
#video-controls-volume-level::-moz-progress-bar {
|
||||
background: #ffffffc0;
|
||||
}
|
||||
|
||||
#video-controls-playback-position {
|
||||
background: #ffffff30;
|
||||
}
|
||||
|
||||
#video-controls-playback-position::-moz-progress-bar {
|
||||
background: #ffffffc0;
|
||||
}
|
||||
/* END FIREFOX */
|
||||
|
||||
#video-controls-playback-position {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#clip-bar {
|
||||
width: 100%;
|
||||
min-height: 7px;
|
||||
background-color: #bbb;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#clip-bar > div {
|
||||
position: absolute;
|
||||
background-color: #d80;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#waveform-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#waveform {
|
||||
width: 100%;
|
||||
|
||||
/* With an unbound height, the waveform can appear a bit away from the video.
|
||||
* The intended effect still works if we scrunch the height a bit, so here's
|
||||
* a height bound for the waveform image.
|
||||
*/
|
||||
max-height: 100px;
|
||||
filter: invert(90%);
|
||||
}
|
||||
|
||||
#waveform-marker {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: #dd8800a0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#range-definitions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.range-transition-duration-section {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.range-transition-duration {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.range-definition-times {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.range-definition-start,
|
||||
.range-definition-end {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.range-definition-between-time-gap {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.range-definition-icon-gap {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
#add-range-definition {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.range-definition-chapter-markers > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
.range-definition-chapter-marker-start-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.range-definition-chapter-marker-start {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.range-definition-chapter-marker-edit-gap {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
input.range-definition-chapter-marker-description {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.add-range-definition-chapter-marker {
|
||||
margin-left: 30px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
#video-info {
|
||||
margin: 5px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-rows: minmax(min-content, max-content) 1.25em 3em minmax(4em, max-content) 1.25em;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#video-info-editor-notes-container {
|
||||
border: 1px solid #666;
|
||||
background-color: #125;
|
||||
grid-column-end: span 2;
|
||||
}
|
||||
|
||||
/* In order to maintain the grid dimensions, when we hide the editors notes (for there not being them),
|
||||
* they still need to take up a grid slot. As such, we replace `display: none` in this context with
|
||||
* an effective equivalent that doesn't remove its rendering entirely.
|
||||
*/
|
||||
#video-info-editor-notes-container.hidden {
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#video-info-title-full {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#video-info-title {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#video-info-title-abbreviated {
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
height: 2em;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
/* For some reason, all this Webkit-specific-looking stuff is required to show ellipses
|
||||
on wrapped text.
|
||||
It also somehow works on Firefox. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-mode-options {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-position {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
#video-info-thumbnail-template-preview-image {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
#video-info-thumbnail-template-video-source-image {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
#video-info-thumbnail-template-overlay-image {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-advanced-crop-flex-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-advanced-crop-flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-info-thumbnail-advanced-crop-flex-column div {
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
.submission-response-error {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#submission {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
#download {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
#data-correction {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
#data-correction-force-reset-confirm p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.submission-response-pending {
|
||||
color: #cc0;
|
||||
}
|
||||
|
||||
.submission-response-error {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.submission-response-success {
|
||||
color: #0c0;
|
||||
}
|
||||
|
||||
.time-converter-time {
|
||||
display: block;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
#chat-replay {
|
||||
overflow-y: auto;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.chat-replay-message {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-replay-message-time {
|
||||
flex-basis: 110px;
|
||||
color: #ccc;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chat-replay-message-text {
|
||||
flex-basis: 200px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.chat-replay-message-text-action {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-replay-message-system {
|
||||
color: #aaf;
|
||||
}
|
||||
|
||||
.chat-replay-message-text-action .chat-replay-message-reply:not(.chat-replay-message-text-action) {
|
||||
font-style: normal; /* Clear the italics from the action */
|
||||
}
|
||||
|
||||
.chat-replay-message-emote {
|
||||
/*
|
||||
This size is set based on Twitch's 1.0 emote size.
|
||||
This will need to be updated if that changes. (Otherwise, auto-scrolling will break.)
|
||||
*/
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.chat-replay-message-reply {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.chat-replay-message-reply a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-replay-message-cleared {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chat-replay-message-cleared .chat-replay-message-text {
|
||||
text-decoration: line-through;
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table > form {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
#template-list-data {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#template-list-data td {
|
||||
border: 1px solid #000;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.template-list-preview {
|
||||
width: 480px;
|
||||
}
|
||||
|
||||
#template-new-errors,
|
||||
.template-data-edit-errors {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
#template-new-form-fields {
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
#template-new-form-fields > div {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.template-coord {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
#google-authentication {
|
||||
margin-top: 5px;
|
||||
}
|
@ -1,155 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>VST Thumbnail Template Management</title>
|
||||
|
||||
<meta
|
||||
name="google-signin-client_id"
|
||||
content="345276493482-r84m2giavk10glnmqna0lbq8e1hdaus0.apps.googleusercontent.com"
|
||||
/>
|
||||
<script src="https://apis.google.com/js/platform.js?onload=onGLoad" async defer></script>
|
||||
<link rel="stylesheet" href="styles/thumbnails.css" />
|
||||
<script src="scripts/thumbnails.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="template-list">
|
||||
<h1>Template List</h1>
|
||||
<table id="template-list-data">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Attribution</th>
|
||||
<th>Crop Coordinates</th>
|
||||
<th>Location Coordinates</th>
|
||||
<th>Preview</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="template-new">
|
||||
<h1>Add New Template</h1>
|
||||
<ul id="template-new-errors"></ul>
|
||||
<form id="template-new-form">
|
||||
<div id="template-new-form-fields">
|
||||
<div>
|
||||
<label for="template-new-name">Name:</label>
|
||||
<input type="text" name="name" id="template-new-name" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="template-new-image">Image:</label>
|
||||
<input type="file" id="template-new-image" name="image" accept="image/png" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="template-new-description">Description:</label>
|
||||
<textarea id="template-new-description" name="description"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="template-new-attribution">Attribution:</label>
|
||||
<input type="text" id="template-new-attribution" name="attribution" />
|
||||
</div>
|
||||
<div>
|
||||
<span>Crop:</span>
|
||||
<span>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-crop-x-start"
|
||||
name="cropxstart"
|
||||
placeholder="X"
|
||||
min="0"
|
||||
step="1"
|
||||
value="182"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-crop-y-start"
|
||||
name="cropystart"
|
||||
placeholder="Y"
|
||||
min="0"
|
||||
step="1"
|
||||
value="0"
|
||||
/>
|
||||
to
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-crop-x-end"
|
||||
name="cropxend"
|
||||
placeholder="X"
|
||||
min="0"
|
||||
step="1"
|
||||
value="1738"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-crop-y-end"
|
||||
name="cropyend"
|
||||
placeholder="Y"
|
||||
min="0"
|
||||
step="1"
|
||||
value="824"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>Location:</span>
|
||||
<span>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-location-x-start"
|
||||
name="locxstart"
|
||||
placeholder="X"
|
||||
min="0"
|
||||
step="1"
|
||||
value="45"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-location-y-start"
|
||||
name="locystart"
|
||||
placeholder="Y"
|
||||
min="0"
|
||||
step="1"
|
||||
value="45"
|
||||
/>
|
||||
to
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-location-x-end"
|
||||
name="locxend"
|
||||
placeholder="X"
|
||||
min="0"
|
||||
step="1"
|
||||
value="1235"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="template-coord"
|
||||
id="template-new-location-y-end"
|
||||
name="locyend"
|
||||
placeholder="Y"
|
||||
min="0"
|
||||
step="1"
|
||||
value="675"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Add Template</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="google-authentication">
|
||||
<div id="google-auth-sign-in" class="g-signin2" data-onsuccess="googleOnSignIn"></div>
|
||||
<a href="#" id="google-auth-sign-out" class="hidden">Sign Out of Google Account</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Thrimbletrimmer - Utilities</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/thumbnails.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"types": ["vite/client"],
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Thrimbletrimmer - Utilities</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/utils.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,23 @@
|
||||
import { fileURLToPath } from "url";
|
||||
import { defineConfig } from "vite";
|
||||
import solidPlugin from "vite-plugin-solid";
|
||||
import devtools from "solid-devtools/vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/thrimbletrimmer/",
|
||||
plugins: [devtools(), solidPlugin()],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: fileURLToPath(new URL("index.html", import.meta.url)),
|
||||
edit: fileURLToPath(new URL("edit.html", import.meta.url)),
|
||||
utils: fileURLToPath(new URL("utils.html", import.meta.url)),
|
||||
thumbnails: fileURLToPath(new URL("thumbnails.html", import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|