Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5650f82d77 | ||
|
|
bd4628efea | ||
|
|
e0e6b83162 | ||
|
|
b088c5bf85 | ||
|
|
78e7d06fd5 | ||
|
|
3e335e72e3 | ||
|
|
a710dcf130 | ||
|
|
2134290895 | ||
|
|
7cc765ddc4 | ||
|
|
95aa404ecd | ||
|
|
488cce9beb | ||
|
|
51038c5e54 | ||
|
|
65d8e3d591 | ||
|
|
a1657fb48c | ||
|
|
b0b65ff77f | ||
|
|
2260e251d9 | ||
|
|
d1ee10260b | ||
|
|
b574abbc9d | ||
|
|
756d14b620 | ||
|
|
be41430c92 | ||
|
|
eaaa3e8667 | ||
|
|
ac2c724069 | ||
|
|
7992cdaa6c | ||
|
|
85550498fb | ||
|
|
65f8194f3d | ||
|
|
5256dbc0b7 | ||
|
|
89dfaeda90 | ||
|
|
d296629d29 | ||
|
|
64839c8bd1 | ||
|
|
9fa56ec7b9 | ||
|
|
52806907c7 | ||
|
|
4ec700af70 | ||
|
|
39ca665c57 | ||
|
|
6d2e349b55 | ||
|
|
6d41d8bbcd | ||
|
|
f3ec4618b5 | ||
|
|
1cf4474b13 | ||
|
|
c6b42ac0b5 | ||
|
|
f77038ef57 | ||
|
|
960602aee2 | ||
|
|
6d069c5bf8 | ||
|
|
0688855502 | ||
|
|
03abaf9f60 | ||
|
|
ad36c44442 | ||
|
|
00f39c74d2 | ||
|
|
e433023a7a | ||
|
|
2e825ba5d2 | ||
|
|
4fe036d7f5 | ||
|
|
7defd98af9 | ||
|
|
18be882141 | ||
|
|
4661af0d7c | ||
|
|
adf4497563 | ||
|
|
1526fe5623 | ||
|
|
25aeee6a68 | ||
|
|
90618972ea | ||
|
|
8c7fa019d1 | ||
|
|
6e16d6a59f | ||
|
|
b7c9024b1e |
@@ -0,0 +1,14 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: npm ci
|
||||
- run: npx wrangler deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
+3
-4
@@ -1,4 +1,3 @@
|
||||
/_*
|
||||
/*.pid
|
||||
/*.log
|
||||
/v4/yarn
|
||||
node_modules
|
||||
.wrangler
|
||||
assets/*.js*
|
||||
@@ -0,0 +1 @@
|
||||
@sugoidogo:registry=https://gitea.sugoidogo.com/api/packages/sugoidogo/npm/
|
||||
@@ -1,25 +1,11 @@
|
||||
# PNGTube
|
||||
Become a PNGTuber without using discord
|
||||
OBS Browser Source for creating an image based avatar that animates to audio input. Useful for PNG-tubing or as a TTS avatar.
|
||||
## Usage
|
||||
0. Check your OBS version by reading the title bar while it's open
|
||||
- If it's under 27 (26 or lower), you need to update OBS before using this overlay
|
||||
- If it's 27, install the [Websocket Plugin Beta](https://github.com/obsproject/obs-websocket/releases)
|
||||
1. Open OBS
|
||||
2. Check your OBS version by reading the window title
|
||||
- If it's under 27 (26 or lower), you need to update OBS
|
||||
- If it's 27, close OBS and install the [OBS Websocket Plugin](https://github.com/obsproject/obs-websocket/releases/tag/5.0.1)
|
||||
- If it's over 27 (28 or higher), you can skip this step
|
||||
1. Add a browser source with the url https://sugoidogo.github.io/pngtube2/v4/overlay.html
|
||||
- **The overlay will be empty when you first add it. This is normal.**
|
||||
2. Add a custom browser dock (Docks > Custom Browser Docks) with the url https://sugoidogo.github.io/pngtube2/v4/dock.html
|
||||
- to run multiple instances, add `?id=somename` to both the dock and overlay url.
|
||||
- replace `somename` with something identifiable
|
||||
- the dock and overlay must have the same id to be able to change settings
|
||||
3. Add your images and any password that is set in the websocket settings (Tools > OBS-Websocket Settings)
|
||||
4. **Add a Noise Gate Filter to the source you plan to use.** The audio levels reported by obs are in a format I don't understand. You are welcome to open an issue if you can detail how to convert the values into decibles, like shown on OBS's audio mixer. For now, you should adjust some settings on the noise gate:
|
||||
1. Close Threshold: How quiet your audio source must be before it's cut off.
|
||||
2. Open Threshold: How loud your audio source must be before it's audible again.
|
||||
- If you don't understand what these numbers mean, look at the audio mixer in obs. It has numbers along the meter to show you where your audio levels are at.
|
||||
3. Release Time: How long the audio level has to stay under the Close Threshold to get cut off.
|
||||
- This value is in milliseconds. 1000 milliseconds equals 1 second. Keep in mind that this determines how long it takes for audio to cut off.
|
||||
5. You're Done! You can close the dock now and open it again later to change settings under the Dock menu in OBS.
|
||||
## Support
|
||||
[Get support on Discord](https://discord.gg/zxDnYSvMNw)
|
||||
|
||||
[Give support on Patreon](https://www.patreon.com/SugoiDogo)
|
||||
2. With OBS open, visit the [PNGTube Setup Page](https://pngtube.sugoidogo.com/) and authorize OBS.
|
||||
3. After configuring all your settings, hit 'Save & Download' to get your avatar.
|
||||
You can drag & drop this file into your scene in OBS, or create a browser source, check the 'Local file' option, and select your avatar.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<style>
|
||||
body {
|
||||
--blink-min: 1;
|
||||
--blink-max: 10;
|
||||
--blink-time: 0.1;
|
||||
}
|
||||
|
||||
#active-blink.blink.active,
|
||||
#idle-blink.blink:not(.active) {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
<script type="module">
|
||||
(function blink(){
|
||||
const style=getComputedStyle(document.body)
|
||||
const min=+style.getPropertyValue('--blink-min')
|
||||
const max=+style.getPropertyValue('--blink-max')
|
||||
const time=+style.getPropertyValue('--blink-time')
|
||||
console.debug(min,max,time)
|
||||
for(const element of document.querySelectorAll('*')){
|
||||
element.classList.add('blink')
|
||||
}
|
||||
setTimeout(()=>{
|
||||
for(const element of document.querySelectorAll('*')){
|
||||
element.classList.remove('blink')
|
||||
}
|
||||
},time*1000)
|
||||
setTimeout(blink, (Math.random() * (max - min) + min)*1000)
|
||||
})()
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
img {
|
||||
--bounce-height: 5%;
|
||||
height: calc(100% - var(--bounce-height));
|
||||
bottom: 0%;
|
||||
}
|
||||
|
||||
.active {
|
||||
animation: bounce 0.5s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0% {
|
||||
bottom: 0%;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
50% {
|
||||
bottom: var(--bounce-height);
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 0%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#idle, #idle-blink {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"assets":[
|
||||
{
|
||||
"id":"active",
|
||||
"url":"tubie/active.png"
|
||||
},
|
||||
{
|
||||
"id": "active-blink",
|
||||
"url": "tubie/active-blink.png"
|
||||
},
|
||||
{
|
||||
"id": "idle",
|
||||
"url": "tubie/idle.png"
|
||||
},
|
||||
{
|
||||
"id": "idle-blink",
|
||||
"url": "tubie/idle-blink.png"
|
||||
},
|
||||
{
|
||||
"id":"bounce_animation",
|
||||
"url":"bounce.css"
|
||||
},
|
||||
{
|
||||
"id":"idle_half_brightness",
|
||||
"url":"brightness.css"
|
||||
},
|
||||
{
|
||||
"id":"blink_animation",
|
||||
"url":"blink.html"
|
||||
}
|
||||
],
|
||||
"sources":[
|
||||
"Mic/Aux"
|
||||
],
|
||||
"obs_token":"ws://localhost:4455"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
img {
|
||||
bottom:-100%;
|
||||
transition-property: bottom;
|
||||
transition-duration: 0.5s;
|
||||
transition-delay: 1s;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
img.active{
|
||||
bottom:0%;
|
||||
transition-delay: 0s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<head>
|
||||
<link rel="icon" href="data:image/png;base64,iVBORw0KGgo=">
|
||||
<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" media="screen" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura-dark.css"
|
||||
media="screen and (prefers-color-scheme: dark)" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<h1>PNGTube</h1>
|
||||
<select id="instance" hidden></select>
|
||||
<form name="new_instance" hidden>
|
||||
<input type="text" name="instance_name" required>
|
||||
<button type="submit">Create new instance</button>
|
||||
</form>
|
||||
<form name="instance_config" hidden>
|
||||
<button type="button" id="remove_instance">Delete</button>
|
||||
<br>
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<h2>Assets</h2>
|
||||
<div id="asset_warning" hidden>
|
||||
Possible missing assets:
|
||||
<ul id="missing_assets"></ul>
|
||||
Make sure all your assets have been added,
|
||||
<br>
|
||||
and their id's are set correctly below.
|
||||
<br>
|
||||
If you have already done so, ignore this warning.
|
||||
<br><br>
|
||||
</div>
|
||||
<input type="file" id="asset_file_input" multiple>
|
||||
<ul id="asset_list"></ul>
|
||||
</td>
|
||||
<td>
|
||||
<h2>Activation</h2>
|
||||
<div id="obs_config">
|
||||
<span id="obs_status">Connecting to OBS...</span>
|
||||
<input type="hidden" name="obs_token">
|
||||
<button type="button" id="obs_auth">Get OBS Authorization</button>
|
||||
</div>
|
||||
<br>
|
||||
<button type="button" id="add_source">Add Source</button>
|
||||
<ul id="source_list"></ul>
|
||||
</td>
|
||||
<td id="instructions">
|
||||
<h3>Adjusting Sensitivity</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td colspan="2">Noise Gate Settings</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td>Close Threshold</td>
|
||||
<td>Idle volume level</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Open Threshold</td>
|
||||
<td>Active volume level</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hold Time</td>
|
||||
<td>How long to hold before switching back to idle</td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
PNGTube will consider your audio sources 'active'
|
||||
if there is any sound coming through at all.
|
||||
The volume meters in OBS won't show sounds quieter than -60db,
|
||||
so even if you think it's silent,
|
||||
most audio devices have some static noise in them.
|
||||
The "Adjust Sensitivity" button on the left will open the filter panel,
|
||||
and add a noise gate if you don't have one already.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div id="status">Loading...</div>
|
||||
<button type="submit">Save & Download</button>
|
||||
</form>
|
||||
|
||||
<datalist id="element_references"></datalist>
|
||||
|
||||
<datalist id="obs_sources"></datalist>
|
||||
|
||||
<span id="templates" hidden>
|
||||
<li class="asset">
|
||||
<form name="assets">
|
||||
<button type="button" onclick="this.parentElement.parentElement.remove()">remove</button>
|
||||
<input type="search" name="id" required pattern="[\w-]+" list="element_references">
|
||||
<span id="filename"></span>
|
||||
<input type="hidden" name="url">
|
||||
</form>
|
||||
</li>
|
||||
<li class="source">
|
||||
<button type="button" onclick="this.parentElement.remove()">remove</button>
|
||||
<input type="search" name="sources" list="obs_sources" required pattern=".+">
|
||||
<br>
|
||||
<button type="button" class="sensitivity">Adjust Sensitivity</button>
|
||||
</li>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
form[name=new_instance]:not([hidden]),
|
||||
form[name=instance_config]:not([hidden]){
|
||||
display: inline;
|
||||
}
|
||||
td {
|
||||
border: 1px solid lightgray;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
width: fit-content;
|
||||
}
|
||||
#instructions {
|
||||
white-space: normal;
|
||||
width: 20em;
|
||||
}
|
||||
button#add_source,
|
||||
button.sensitivity {
|
||||
width: 100%;
|
||||
}
|
||||
.source {
|
||||
margin: 10px;
|
||||
}
|
||||
form, input[type=search] {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="index.js"></script>
|
||||
@@ -0,0 +1,24 @@
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
bottom: 0%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#idle:not(.active) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#active.active {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 520 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 521 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 416 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 423 KiB |
-330
@@ -1,330 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>PNGTuber</title>
|
||||
</head>
|
||||
<div id="imgdiv">
|
||||
<img id="img" class="img">
|
||||
<img id="simg" class="img">
|
||||
</div>
|
||||
<div id="controls">
|
||||
<img id="logo" src="logo.png" />
|
||||
<p>Volume threshold</p>
|
||||
<input type="range" id="volume-range" min="1" max="100" oninput="onVolumeRange(this.value)" />
|
||||
<input type="number" id="volume-number" min="1" max="100" oninput="onVolumeNumber(this.value)" />
|
||||
<p>Idle brightness</p>
|
||||
<input type="range" id="brightness-range" min="1" max="100" oninput="onBrightnessRange(this.value)" />
|
||||
<input type="number" id="brightness-number" min="1" max="100" oninput="onBrightnessNumber(this.value)" />
|
||||
<p>Animation transition time</p>
|
||||
<input type="range" id="time-range" min="0" max="1" step="0.01" oninput="onTimeRange(this.value)" />
|
||||
<input type="number" id="time-number" min="0" max="1" step="0.01" oninput="onTimeNumber(this.value)" />
|
||||
<p>Silence transition delay</p>
|
||||
<input type="range" id="delay-range" min="0" max="1" step="0.1" oninput="onDelayRange(this.value)" />
|
||||
<input type="number" id="delay-number" min="0" max="1" step="0.1" oninput="onDelayNumber(this.value)" />
|
||||
<p>Jump height</p>
|
||||
<input type="range" id="height-range" min="0" max="100" oninput="onHeightRange(this.value)" />
|
||||
<input type="number" id="height-number" min="0" max="100" oninput="onHeightNumber(this.value)" />
|
||||
<p>Idle image</p>
|
||||
<input type="file" accept="image/*" oninput="setIdle(this.files[0])" />
|
||||
<p>Talking image</p>
|
||||
<input type="file" accept="image/*" oninput="setTalking(this.files[0])" />
|
||||
</div>
|
||||
<style>
|
||||
* {
|
||||
font-size: 4vmin;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.img {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
#img {
|
||||
opacity: 0;
|
||||
transition-property: top;
|
||||
}
|
||||
|
||||
#imgdiv {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#controls:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#controls {
|
||||
width: 99vw;
|
||||
height: 99vh;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
background-color: rgba(127, 127, 127, 0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#logo {
|
||||
max-width: 100vw;
|
||||
max-height: 25vh;
|
||||
}
|
||||
|
||||
input[type=range] {
|
||||
width: 75vw;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 1vw;
|
||||
height: 3vh;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 75vw;
|
||||
height: 3vh;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#volume-range {
|
||||
--volume: 0%;
|
||||
position: relative;
|
||||
width: 75vw;
|
||||
height: 3vh;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#volume-range::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--volume);
|
||||
background-color: rgba(0, 255, 0, 0.5);
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
</style>
|
||||
<script src="https://localforage.github.io/localForage/localforage.min.js"></script>
|
||||
<script>
|
||||
const VOLUME = "volume";
|
||||
const VOLUME_RANGE = document.getElementById("volume-range");
|
||||
const VOLUME_NUMBER = document.getElementById("volume-number");
|
||||
const BRIGHTNESS = "brightness";
|
||||
const BRIGHTNESS_RANGE = document.getElementById("brightness-range");
|
||||
const BRIGHTNESS_NUMBER = document.getElementById("brightness-number");
|
||||
const TIME="time";
|
||||
const TIME_RANGE=document.getElementById("time-range");
|
||||
const TIME_NUMBER=document.getElementById("time-number");
|
||||
const DELAY="delay"
|
||||
const DELAY_RANGE=document.getElementById("delay-range");
|
||||
const DELAY_NUMBER=document.getElementById("delay-number");
|
||||
const HEIGHT="height";
|
||||
const HEIGHT_RANGE=document.getElementById("height-range");
|
||||
const HEIGHT_NUMBER=document.getElementById("height-number");
|
||||
const IDLE_IMAGE = "idle-image";
|
||||
const TALKING_IMAGE = "talking-image";
|
||||
const IMAGE = document.getElementById("img");
|
||||
const SIMAGE = document.getElementById("simg");
|
||||
const DEFAULT_VOLUME = 50;
|
||||
const DEFAULT_BRIGHTNESS = 90;
|
||||
const DEFAULT_TIME=0.2;
|
||||
const DEFAULT_HEIGHT=5;
|
||||
const DEFAULT_IDLE = "";
|
||||
const DEFAULT_TALKING = "";
|
||||
const DEFAULT_DELAY=0.5;
|
||||
const LEGACY_BRIGHTNESS = "darkness";
|
||||
const LEGACY_IDLE = "closed";
|
||||
const LEGACY_TALKING = "open";
|
||||
const storage = localforage;
|
||||
|
||||
let volume;
|
||||
let brightness;
|
||||
let time;
|
||||
let idle;
|
||||
let talking;
|
||||
let height;
|
||||
let delay;
|
||||
|
||||
function setHeight(input){
|
||||
height=input;
|
||||
IMAGE.style.maxHeight=(100-height)+"vh";
|
||||
IMAGE.style.top=height+"vh";
|
||||
SIMAGE.style.maxHeight=(100-height)+"vh";
|
||||
SIMAGE.style.top=height+"vh";
|
||||
}
|
||||
|
||||
function setTime (input) {
|
||||
document.getElementById("img").style.transitionDuration=input+'s';
|
||||
time=input;
|
||||
}
|
||||
|
||||
function setIdle(input) {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", function () {
|
||||
SIMAGE.src = reader.result;
|
||||
storage.setItem(IDLE_IMAGE, reader.result);
|
||||
}, false);
|
||||
reader.readAsDataURL(input);
|
||||
}
|
||||
|
||||
function setTalking(input) {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", function () {
|
||||
IMAGE.src = reader.result;
|
||||
storage.setItem(TALKING_IMAGE, reader.result);
|
||||
}, false);
|
||||
reader.readAsDataURL(input);
|
||||
}
|
||||
|
||||
function setBrightness(input){
|
||||
brightness = input;
|
||||
SIMAGE.style.setProperty('filter', 'brightness(' + brightness + '%)');
|
||||
}
|
||||
|
||||
function onVolumeRange(input) {
|
||||
volume = input;
|
||||
VOLUME_NUMBER.value = input;
|
||||
storage.setItem(VOLUME, input);
|
||||
}
|
||||
|
||||
function onVolumeNumber(input) {
|
||||
volume = input;
|
||||
VOLUME_RANGE.value = input;
|
||||
storage.setItem(VOLUME, input);
|
||||
}
|
||||
|
||||
function onBrightnessRange(input) {
|
||||
setBrightness(input)
|
||||
BRIGHTNESS_NUMBER.value = input;
|
||||
storage.setItem(BRIGHTNESS, input);
|
||||
}
|
||||
|
||||
function onBrightnessNumber(input) {
|
||||
setBrightness(input)
|
||||
BRIGHTNESS_RANGE.value = input;
|
||||
storage.setItem(BRIGHTNESS, input);
|
||||
}
|
||||
|
||||
function onTimeRange(input) {
|
||||
setTime(input);
|
||||
TIME_NUMBER.value=input;
|
||||
storage.setItem(TIME, input);
|
||||
}
|
||||
|
||||
function onTimeNumber(input){
|
||||
setTime(input);
|
||||
TIME_RANGE.value=input;
|
||||
storage.setItem(TIME, input);
|
||||
}
|
||||
|
||||
function onHeightRange(input){
|
||||
setHeight(input);
|
||||
HEIGHT_NUMBER.value=input;
|
||||
storage.setItem(HEIGHT,input);
|
||||
}
|
||||
|
||||
function onHeightNumber(input){
|
||||
setHeight(input);
|
||||
HEIGHT_RANGE.value=input;
|
||||
storage.setItem(HEIGHT,input);
|
||||
}
|
||||
|
||||
function onDelayRange(input){
|
||||
delay=input;
|
||||
DELAY_NUMBER.value=input;
|
||||
storage.setItem(DELAY,input);
|
||||
}
|
||||
|
||||
function onDelayNumber(input){
|
||||
delay=input;
|
||||
DELAY_RANGE.value=input;
|
||||
storage.setItem(DELAY,input);
|
||||
}
|
||||
|
||||
window.onload = async function () {
|
||||
try {
|
||||
volume = await storage.getItem(VOLUME) || DEFAULT_VOLUME;
|
||||
brightness = await storage.getItem(BRIGHTNESS) || DEFAULT_BRIGHTNESS;
|
||||
idle = await storage.getItem(IDLE_IMAGE) || DEFAULT_IDLE;
|
||||
talking = await storage.getItem(TALKING_IMAGE) || DEFAULT_TALKING;
|
||||
delay = await storage.getItem(DELAY) || DEFAULT_DELAY;
|
||||
|
||||
setHeight(await storage.getItem(HEIGHT) || DEFAULT_HEIGHT);
|
||||
setTime(await storage.getItem(TIME) || DEFAULT_TIME);
|
||||
|
||||
VOLUME_RANGE.value = volume;
|
||||
VOLUME_NUMBER.value = volume;
|
||||
BRIGHTNESS_RANGE.value = brightness;
|
||||
BRIGHTNESS_NUMBER.value = brightness;
|
||||
TIME_RANGE.value=time;
|
||||
TIME_NUMBER.value=time;
|
||||
HEIGHT_NUMBER.value=height;
|
||||
HEIGHT_RANGE.value=height;
|
||||
DELAY_RANGE.value = delay;
|
||||
DELAY_NUMBER.value = delay;
|
||||
IMAGE.src = talking;
|
||||
SIMAGE.src=idle;
|
||||
SIMAGE.style.setProperty('filter', 'brightness(' + brightness + '%)');
|
||||
|
||||
|
||||
const volumeVisualizer = document.getElementById("volume-range");
|
||||
let backoff=null
|
||||
const volumeCallback = function(averageVolume) {
|
||||
averageVolume=Math.min(parseInt(averageVolume.data),100)
|
||||
volumeVisualizer.style.setProperty(
|
||||
"--volume",
|
||||
averageVolume + "%"
|
||||
);
|
||||
if (averageVolume <= volume) {
|
||||
if(backoff==null && IMAGE.style.opacity==1){
|
||||
SIMAGE.style.opacity=1
|
||||
IMAGE.style.opacity=0
|
||||
}
|
||||
} else {
|
||||
if(backoff!=null){
|
||||
clearTimeout(backoff)
|
||||
}
|
||||
backoff=setTimeout(()=>backoff=null,Math.max(time*2000,delay*1000))
|
||||
if (IMAGE.style.opacity==0) {
|
||||
IMAGE.style.opacity=1
|
||||
SIMAGE.style.opacity=0
|
||||
IMAGE.style.transitionTimingFunction='ease-out'
|
||||
IMAGE.style.top="0vh";
|
||||
|
||||
setTimeout(()=>{
|
||||
IMAGE.style.top=height+"vh";
|
||||
IMAGE.style.transitionTimingFunction='ease-in'
|
||||
},time*1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startAudio=function (event=null){
|
||||
if(event){
|
||||
console.log(event)
|
||||
}
|
||||
let ev=new EventSource('http://localhost:5000/audio')
|
||||
ev.onmessage=volumeCallback;
|
||||
ev.onerror=startAudio;
|
||||
window.onbeforeunload=function(event=null){
|
||||
ev.close()
|
||||
}
|
||||
}
|
||||
|
||||
startAudio();
|
||||
} catch (e) {
|
||||
window.alert(e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Generated
+1653
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"scripts": {
|
||||
"test": "wrangler dev --ip=localhost"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.56.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sugoidogo/js-util": "^0.2.0",
|
||||
"localforage": "^1.10.0",
|
||||
"obs-websocket-js": "^5.0.7"
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
import { FileReaderAsync, getFormDataDeep } from "@sugoidogo/js-util"
|
||||
import localforage from "localforage"
|
||||
import OBSWebSocket from "obs-websocket-js/json"
|
||||
|
||||
/** @type {Storage} */
|
||||
const localStorage = localforage.createInstance({ name: 'pngtube.v7' })
|
||||
const obs = new OBSWebSocket()
|
||||
/** @type {HTMLDivElement} */
|
||||
const status_div = document.querySelector('div#status')
|
||||
/** @type {HTMLSelectElement} */
|
||||
const instance_select = document.querySelector('select#instance')
|
||||
/** @type {HTMLFormElement} */
|
||||
const new_instance_form = document.querySelector('form[name=new_instance]')
|
||||
/** @type {HTMLFormElement} */
|
||||
const instance_config_form = document.querySelector('form[name=instance_config')
|
||||
/** @type {HTMLInputElement} */
|
||||
const tts_checkbox = document.querySelector('input[name=tts]')
|
||||
/** @type {HTMLInputElement} */
|
||||
const source_input = document.querySelector('input#sources')
|
||||
/** @type {HTMLUListElement} */
|
||||
const source_list = document.querySelector('ul#source_list')
|
||||
/** @type {HTMLUListElement} */
|
||||
const asset_list = document.querySelector('ul#asset_list')
|
||||
/** @type {HTMLInputElement} */
|
||||
const asset_file_input = document.querySelector('input#asset_file_input')
|
||||
/** @type {HTMLSpanElement} */
|
||||
const obs_status_span = document.querySelector('span#obs_status')
|
||||
/** @type {HTMLInputElement} */
|
||||
const obs_token_input = document.querySelector('input[name=obs_token]')
|
||||
/** @type {HTMLDataListElement} */
|
||||
const obs_sources_datalist = document.querySelector('datalist#obs_sources')
|
||||
/** @type {HTMLSpanElement} */
|
||||
const templates = document.querySelector('span#templates')
|
||||
function set_loading(loading) {
|
||||
if (loading) {
|
||||
status_div.innerHTML = 'Loading...'
|
||||
status_div.hidden = false
|
||||
instance_select.hidden = true
|
||||
new_instance_form.hidden = true
|
||||
instance_config_form.hidden = true
|
||||
} else {
|
||||
instance_select.hidden = false
|
||||
status_div.hidden = true
|
||||
}
|
||||
}
|
||||
async function updateReferences() {
|
||||
let refs = ['idle', 'active']
|
||||
for (const asset_list_item of asset_list.children) {
|
||||
const asset_url_input = asset_list_item.querySelector('input[name=url]')
|
||||
const asset_dataurl = asset_url_input.value
|
||||
if (asset_dataurl.startsWith('data:text')) {
|
||||
const asset_text = await fetch(asset_dataurl, { cache: 'no-cache' }).then(response => response.text())
|
||||
const asset_references = asset_text.matchAll(/#[\w-]+/g)
|
||||
for (const asset_reference of asset_references) {
|
||||
refs.push(asset_reference[0].substring(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
refs = [...new Set(refs)]
|
||||
/** @type {HTMLDataListElement} */
|
||||
const element_references_datalist = document.querySelector('datalist#element_references')
|
||||
while (element_references_datalist.childElementCount > 0) {
|
||||
element_references_datalist.firstElementChild.remove()
|
||||
}
|
||||
for (const ref of refs) {
|
||||
const option = document.createElement('option')
|
||||
option.value = ref
|
||||
element_references_datalist.appendChild(option)
|
||||
}
|
||||
for (const asset_list_item of asset_list.children) {
|
||||
const asset_id_input = asset_list_item.querySelector('input[name=id]')
|
||||
const asset_id = asset_id_input.value
|
||||
if (refs.includes(asset_id)) {
|
||||
refs.splice(refs.indexOf(asset_id), 1)
|
||||
}
|
||||
}
|
||||
const asset_warning_div = document.querySelector('div#asset_warning')
|
||||
if (refs.length != 0) {
|
||||
const missing_assets_list = asset_warning_div.querySelector('ul#missing_assets')
|
||||
while (missing_assets_list.childElementCount > 0) {
|
||||
missing_assets_list.firstElementChild.remove()
|
||||
}
|
||||
for (const ref of refs) {
|
||||
const listItem = document.createElement('li')
|
||||
listItem.innerHTML = ref
|
||||
missing_assets_list.appendChild(listItem)
|
||||
}
|
||||
asset_warning_div.hidden = false
|
||||
} else {
|
||||
asset_warning_div.hidden = true
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {File|String} source
|
||||
* @param {String} id
|
||||
*/
|
||||
async function add_asset(source, id = undefined) {
|
||||
const asset_list_item = templates.querySelector('li.asset').cloneNode(true)
|
||||
const asset_id = asset_list_item.querySelector('input[name=id]')
|
||||
const asset_file_name = asset_list_item.querySelector('span#filename')
|
||||
const asset_url = asset_list_item.querySelector('input[name=url]')
|
||||
const asset_remove_button = asset_list_item.querySelector('button')
|
||||
asset_remove_button.addEventListener('click', updateReferences)
|
||||
if (!(source instanceof File)) {
|
||||
let filename = null
|
||||
if (!source.startsWith('data:')) {
|
||||
filename = source.split('/').at(-1)
|
||||
}
|
||||
source = await fetch(source, { cache: 'no-cache' }).then(response => response.blob())
|
||||
if (!filename) {
|
||||
filename = id + '.' + source.type.split('/')[1]
|
||||
}
|
||||
source = new File([source], filename, { type: source.type })
|
||||
}
|
||||
asset_file_name.innerHTML = source.name
|
||||
asset_url.value = await FileReaderAsync.readAs('DataURL', source)
|
||||
if (id) {
|
||||
asset_id.value = id
|
||||
} else {
|
||||
asset_id.value = source.name.split('.')[0]
|
||||
}
|
||||
asset_id.oninput = updateReferences
|
||||
asset_list.appendChild(asset_list_item)
|
||||
updateReferences()
|
||||
}
|
||||
/** @param {HTMLInputElement} source_input */
|
||||
async function adjust_sensitivity(source_input) {
|
||||
obs.call('GetSourceFilterList', { sourceName: source_input.value })
|
||||
.then((response) => {
|
||||
for (const filter of response.filters) {
|
||||
if (filter.filterKind = 'noise_gate_filter') {
|
||||
obs.call('OpenInputFiltersDialog', { inputName: source_input.value })
|
||||
return
|
||||
}
|
||||
}
|
||||
obs.call('CreateSourceFilter', {
|
||||
sourceName: source_input.value,
|
||||
filterName: 'Noise Gate for PNGTube',
|
||||
filterKind: 'noise_gate_filter',
|
||||
filterSettings: {
|
||||
close_threshold: -60,
|
||||
open_threshold: -30
|
||||
}
|
||||
}).then((response) => {
|
||||
obs.call('OpenInputFiltersDialog', { inputName: source_input.value })
|
||||
})
|
||||
}).catch(error => {
|
||||
if (error.message = 'Not connected') {
|
||||
alert('Not connected to OBS')
|
||||
}
|
||||
})
|
||||
}
|
||||
async function add_source(name = 'Mic/Aux') {
|
||||
const source_list_item = templates.querySelector('li.source').cloneNode(true)
|
||||
const source_input = source_list_item.querySelector('input[name=sources]')
|
||||
const source_sensitivity_button = source_list_item.querySelector('button.sensitivity')
|
||||
source_input.value = name
|
||||
source_sensitivity_button.onclick = () => adjust_sensitivity(source_input)
|
||||
source_list.appendChild(source_list_item)
|
||||
}
|
||||
document.querySelector('button#add_source').onclick = () => add_source()
|
||||
async function load_config(instance_name = instance_select.value) {
|
||||
set_loading(true)
|
||||
obs.disconnect()
|
||||
|
||||
for (const list of [asset_list, source_list]) {
|
||||
while (list.childElementCount > 0) {
|
||||
list.firstElementChild.remove()
|
||||
}
|
||||
}
|
||||
|
||||
if (instance_name == '') {
|
||||
set_loading(false)
|
||||
new_instance_form.hidden = false
|
||||
return false
|
||||
}
|
||||
|
||||
const loading_promises = []
|
||||
const config = await localStorage.getItem(instance_name) || await fetch('default.json', { cache: 'no-cache' }).then(response => response.json())
|
||||
|
||||
console.debug(config)
|
||||
|
||||
if (config.obs_token) {
|
||||
obs_token_input.value = config.obs_token
|
||||
obs_init()
|
||||
}
|
||||
if (config.assets) {
|
||||
if (!(config.assets instanceof Array)) {
|
||||
config.assets = [config.assets]
|
||||
}
|
||||
for (const asset of config.assets) {
|
||||
loading_promises.push(add_asset(asset.url, asset.id))
|
||||
}
|
||||
}
|
||||
if (config.sources) {
|
||||
if (typeof config.sources == 'string') {
|
||||
config.sources = [config.sources]
|
||||
}
|
||||
for (const source of config.sources) {
|
||||
loading_promises.push(add_source(source))
|
||||
}
|
||||
}
|
||||
return Promise.allSettled(loading_promises).then(() => {
|
||||
set_loading(false)
|
||||
instance_config_form.hidden = false
|
||||
return config
|
||||
})
|
||||
}
|
||||
instance_select.onchange = () => load_config()
|
||||
async function instance_select_init() {
|
||||
while (instance_select.childElementCount > 0) {
|
||||
instance_select.firstElementChild.remove()
|
||||
}
|
||||
for (const instance_name of await localStorage.keys()) {
|
||||
const option = document.createElement('option')
|
||||
option.value = instance_name
|
||||
option.innerHTML = instance_name
|
||||
instance_select.appendChild(option)
|
||||
}
|
||||
const option = document.createElement('option')
|
||||
option.innerHTML = 'Create New Instance'
|
||||
option.value = ''
|
||||
instance_select.appendChild(option)
|
||||
instance_select.hidden = false
|
||||
}
|
||||
async function add_instance(name) {
|
||||
const option = document.createElement('option')
|
||||
option.value = name
|
||||
option.innerHTML = name
|
||||
instance_select.prepend(option)
|
||||
instance_select.value = name
|
||||
load_config()
|
||||
}
|
||||
new_instance_form.onsubmit = (event) => {
|
||||
event.preventDefault()
|
||||
add_instance(new_instance_form.instance_name.value)
|
||||
new_instance_form.instance_name.value = ''
|
||||
}
|
||||
asset_file_input.onchange = (event) => {
|
||||
for (const file of asset_file_input.files) {
|
||||
add_asset(file)
|
||||
}
|
||||
asset_file_input.value = ''
|
||||
}
|
||||
obs.on('ConnectionClosed', (error) => {
|
||||
obs_status_span.innerHTML = 'Not connected to OBS'
|
||||
console.warn(error)
|
||||
})
|
||||
obs.on('Identified', () => {
|
||||
obs_status_span.innerHTML = 'Connected to OBS'
|
||||
obs.call('GetInputList')
|
||||
.then((response) => {
|
||||
for (const input of response.inputs) {
|
||||
const option = document.createElement('option')
|
||||
option.value = input.inputName
|
||||
obs_sources_datalist.append(option)
|
||||
}
|
||||
})
|
||||
})
|
||||
obs.on('InputCreated', (event) => {
|
||||
console.debug('source created: ' + event.inputName)
|
||||
const option = document.createElement('option')
|
||||
option.value = event.inputName
|
||||
obs_sources_datalist.append(option)
|
||||
})
|
||||
obs.on('InputRemoved', (event) => {
|
||||
console.debug('source removed: ' + event.inputName)
|
||||
document.querySelector("option[value='" + event.inputName + "']").remove()
|
||||
})
|
||||
obs.on('InputNameChanged', (event) => {
|
||||
console.debug('source renamed :' + event.oldInputName + " > " + event.inputName)
|
||||
document.querySelector("option[value='" + event.oldInputName + "']").value = event.inputName
|
||||
})
|
||||
async function obs_init() {
|
||||
let obsurl
|
||||
let obspassword
|
||||
const search_params = new URLSearchParams(location.search)
|
||||
if (search_params.has('obsurl')) {
|
||||
obsurl = search_params.get('obsurl')
|
||||
obspassword = search_params.get('obspassword')
|
||||
const url = new URL(obsurl)
|
||||
url.password = obspassword
|
||||
obs_token_input.value = url.href
|
||||
} else {
|
||||
if (obs_token_input.value) {
|
||||
const url = new URL(obs_token_input.value)
|
||||
obspassword = url.password
|
||||
url.password = ''
|
||||
obsurl = url.href
|
||||
} else {
|
||||
obs_status_span.innerHTML = 'No OBS Authorization found'
|
||||
}
|
||||
}
|
||||
return obs.connect(obsurl, obspassword)
|
||||
}
|
||||
async function obs_auth() {
|
||||
const url = new URL('https://obsconnect.sugoidogo.com')
|
||||
url.searchParams.append('redirect_uri', location.href)
|
||||
location.assign(url)
|
||||
}
|
||||
document.querySelector('button#obs_auth').onclick = obs_auth
|
||||
instance_config_form.onsubmit = (event) => {
|
||||
event.preventDefault()
|
||||
const config = getFormDataDeep(instance_config_form)
|
||||
console.debug(config)
|
||||
localStorage.setItem(instance_select.value, config)
|
||||
const url = new URL(location.origin + location.pathname)
|
||||
if (url.pathname.endsWith('/')) {
|
||||
url.pathname += 'overlay.html'
|
||||
} else {
|
||||
const path = url.pathname.split('/')
|
||||
path.pop()
|
||||
path.push('overlay.html')
|
||||
url.pathname = path.join('/')
|
||||
}
|
||||
fetch(url).then(response => response.text())
|
||||
.then(async html => {
|
||||
const script = document.createElement('script')
|
||||
script.type = 'module'
|
||||
script.innerHTML = 'const config=' + JSON.stringify(config)+'\n'
|
||||
script.innerHTML+=await fetch(new URL('overlay.js',url)).then(response=>response.text())
|
||||
html += script.outerHTML
|
||||
const blob = new Blob([html])
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = 'pngtube-' + instance_select.value + '.html'
|
||||
a.click()
|
||||
})
|
||||
}
|
||||
async function remove_instance(name = instance_select.value) {
|
||||
localStorage.removeItem(name)
|
||||
.then(instance_select_init)
|
||||
.then(load_config)
|
||||
}
|
||||
document.querySelector('button#remove_instance').onclick = () => remove_instance()
|
||||
|
||||
|
||||
instance_select_init()
|
||||
.then(load_config)
|
||||
@@ -0,0 +1,69 @@
|
||||
import { loadAsset } from '@sugoidogo/js-util'
|
||||
import OBSWebSocket from 'obs-websocket-js/json'
|
||||
|
||||
console.debug(config)
|
||||
if (!(config.assets instanceof Array)) {
|
||||
config.assets = [config.assets]
|
||||
}
|
||||
if (typeof config.sources == 'string') {
|
||||
config.sources = [config.sources]
|
||||
}
|
||||
for (const source of config.assets) {
|
||||
loadAsset(source.url, source.id, true)
|
||||
}
|
||||
// backoff mechanism
|
||||
let backoff = false
|
||||
function backoffStart(event) {
|
||||
if (event instanceof AnimationEvent && getComputedStyle(event.target, event.pseudoElement).animationIterationCount == 'infinite') {
|
||||
return
|
||||
}
|
||||
backoff = true
|
||||
}
|
||||
function backoffEnd() {
|
||||
backoff = false
|
||||
}
|
||||
window.ontransitionstart = backoffStart
|
||||
window.ontransitionend = backoffEnd
|
||||
window.ontransitioncancel = backoffEnd
|
||||
window.onanimationstart = backoffStart
|
||||
window.onanimationend = backoffEnd
|
||||
window.ontransitioncancel = backoffEnd
|
||||
// animation function
|
||||
let active = false
|
||||
requestAnimationFrame(function animate() {
|
||||
if (!backoff) {
|
||||
for (const img of document.querySelectorAll('img')) {
|
||||
if (active) {
|
||||
img.classList.add('active')
|
||||
} else {
|
||||
img.classList.remove('active')
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animate)
|
||||
})
|
||||
// obs connection
|
||||
const obs = new OBSWebSocket()
|
||||
obs.on('ConnectionClosed', (error) => {
|
||||
window.alert('PNGTube error ' + error.code + ': ' + error.message)
|
||||
})
|
||||
obs.on('Identified', (event) => {
|
||||
console.debug(event)
|
||||
})
|
||||
obs.on('InputVolumeMeters', (event) => {
|
||||
const inputs = event.inputs.filter(input => config.sources.includes(input.inputName))
|
||||
for (const input of inputs) {
|
||||
for (const channel of input.inputLevelsMul) {
|
||||
if (channel[0] > 0) {
|
||||
active = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
active = false
|
||||
})
|
||||
const token = new URL(config.obs_token)
|
||||
const password = token.password
|
||||
token.password = ''
|
||||
const url = token.href
|
||||
obs.connect(url, password, { eventSubscriptions: (1 << 16) })
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<body>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Idle Image</td>
|
||||
<td>
|
||||
<input type="file" accept="image/*" data-id="idle">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Active Image</td>
|
||||
<td>
|
||||
<input type="file" accept="image/*" data-id="active">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OBS URL</td>
|
||||
<td>
|
||||
<input type="url" data-id="url">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OBS Password</td>
|
||||
<td>
|
||||
<input type="password" data-id="password">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Audio Sources</td>
|
||||
<td>
|
||||
<select data-id="source"></select>
|
||||
<button id="addSource">Add</button>
|
||||
<ul>
|
||||
<li hidden>
|
||||
<button id="removeSource">Remove</button>
|
||||
<label></label>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Idle Brightness</td>
|
||||
<td>
|
||||
<input type="range" data-id="brightness" min="1" max="100">
|
||||
<input type="number" data-id="brightness" min="1" max="100">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Jump Height</td>
|
||||
<td>
|
||||
<input type="range" data-id="height" min="0" max="100" />
|
||||
<input type="number" data-id="height" min="0" max="100" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Animation Time</td>
|
||||
<td>
|
||||
<input type="range" data-id="time" min="0" max="1" step="0.01" />
|
||||
<input type="number" data-id="time" min="0" max="1" step="0.01" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
<script>
|
||||
window.onload = async function () {
|
||||
// set common variables
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const JSUID = await (await fetch('jsuid')).text() + params.get('id')
|
||||
const broadcastChannel = new BroadcastChannel(JSUID)
|
||||
// register oninput function for all inputs
|
||||
for (const element of document.querySelectorAll('input')) {
|
||||
element.oninput = async function () {
|
||||
const value = element.value
|
||||
const id = element.dataset['id']
|
||||
for (const input of element.parentElement.querySelectorAll('input')) {
|
||||
if (input == element) {
|
||||
continue
|
||||
}
|
||||
input.value = value
|
||||
}
|
||||
broadcastChannel.postMessage('set ' + id + ' ' + value)
|
||||
}
|
||||
}
|
||||
// override oninput function for all file inputs
|
||||
for (const element of document.querySelectorAll('[type=file]')) {
|
||||
element.oninput = async function () {
|
||||
for (const file of element.files) {
|
||||
const id = element.dataset['id']
|
||||
const fileReader = new FileReader()
|
||||
fileReader.onload = function () {
|
||||
const value = fileReader.result
|
||||
broadcastChannel.postMessage('set ' + id + ' ' + value)
|
||||
}
|
||||
fileReader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* onclick function for removeSource
|
||||
* @param {Event} event
|
||||
*/
|
||||
async function removeSource(event) {
|
||||
const button = event.target
|
||||
const parent = button.parentElement
|
||||
const label = parent.querySelector('label')
|
||||
const source = label.innerHTML
|
||||
parent.remove()
|
||||
broadcastChannel.postMessage('disable ' + source)
|
||||
console.log(source)
|
||||
}
|
||||
/**
|
||||
* onclick function for addSource
|
||||
* @param {Event} event
|
||||
* @param {String} source
|
||||
*/
|
||||
async function addSource(event = null, sourceName = null) {
|
||||
const select = document.querySelector('[data-id="source"]')
|
||||
const source = select.value
|
||||
const parent = select.parentElement
|
||||
const list = parent.querySelector('ul')
|
||||
const listItem = parent.querySelector('li').cloneNode(true)
|
||||
const label = listItem.querySelector('label')
|
||||
const rbutton = listItem.querySelector('button')
|
||||
rbutton.onclick = removeSource
|
||||
label.innerHTML = sourceName || source
|
||||
listItem.hidden = false
|
||||
list.appendChild(listItem)
|
||||
if (sourceName == null) {
|
||||
broadcastChannel.postMessage('enable ' + source)
|
||||
}
|
||||
}
|
||||
document.querySelector('#addSource').onclick = addSource
|
||||
// register onmessage function for broadcastChannel
|
||||
broadcastChannel.onmessage = async function (event) {
|
||||
const args = event.data.split(' ')
|
||||
switch (args[0]) {
|
||||
case 'ping': {
|
||||
broadcastChannel.postMessage('pong')
|
||||
}
|
||||
case 'pong': { // Both overlay and dock are now online
|
||||
// clear all sources, we're about to get new ones
|
||||
const selector = document.querySelector('[data-id="source"]')
|
||||
selector.innerHTML = ''
|
||||
selector.value = ''
|
||||
const list=document.querySelector('ul')
|
||||
const listItem=document.querySelector('li')
|
||||
list.innerHTML=''
|
||||
list.appendChild(listItem)
|
||||
break
|
||||
}
|
||||
case 'set': { // sync mechanism
|
||||
const id = args[1]
|
||||
const value = args[2]
|
||||
for (const element of document.querySelectorAll('input[data-id=' + id + ']')) {
|
||||
/**
|
||||
* if the value is already set in the ui, reject the change and trigger the input event,
|
||||
* syncing to the existing value. ranges are set by the browser on creation,
|
||||
* and their synced number input always comes next, so never reject, instead allowing the number to set the range.
|
||||
* always reject file inputs, which must be user-selected, triggering sending of any selected files.
|
||||
*/
|
||||
if ((element.value == '' || element.type == 'range' || element.tagName.toLowerCase() == 'select') && element.type != 'file') {
|
||||
element.value = value
|
||||
} else {
|
||||
element.oninput()
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'add': {
|
||||
const value = args[1]
|
||||
const selector = document.querySelector('[data-id="source"]')
|
||||
const option = document.createElement('option')
|
||||
option.value = value
|
||||
option.innerHTML = value
|
||||
selector.appendChild(option)
|
||||
broadcastChannel.postMessage('get source')
|
||||
break
|
||||
}
|
||||
case 'remove': {
|
||||
const value = args[1]
|
||||
if (selector.value == value) {
|
||||
selector.value = ''
|
||||
}
|
||||
const selector = document.querySelector('[data-id="source"]')
|
||||
const option = selector.querySelector('[value="' + value + ']"')
|
||||
option.remove()
|
||||
break
|
||||
}
|
||||
case 'enable': {
|
||||
const source = args[1]
|
||||
addSource(null, source)
|
||||
}
|
||||
case 'disable': {
|
||||
const source=args[1]
|
||||
const label=document.querySelector('[innerHTML="'+source+'"]')
|
||||
const listItem=label.parentElement
|
||||
listItem.remove()
|
||||
}
|
||||
default: {
|
||||
window.alert(JSUID + '-dock did not understand this message:\n' + event.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
// send startup completed ping
|
||||
broadcastChannel.postMessage('ping')
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because one or more lines are too long
-218
@@ -1,218 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<body>
|
||||
<img data-id="idle">
|
||||
<img data-id="active">
|
||||
</body>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
bottom: 0vh;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
[data-id="active"] {
|
||||
opacity: 0;
|
||||
transition-property: bottom;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.rawgit.com/mozilla/localForage/master/dist/localforage.js"></script>
|
||||
<script src="obs-ws.min.cjs"></script>
|
||||
<script>
|
||||
window.onload = async function () {
|
||||
// set persistant variables
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const JSUID = await (await fetch('jsuid')).text() + params.get('id')
|
||||
const broadcastChannel = new BroadcastChannel(JSUID)
|
||||
localStorage = localforage.createInstance({ name: JSUID })
|
||||
const obs = new OBSWebSocket()
|
||||
const SIMAGE = document.querySelector('[data-id="idle"]')
|
||||
const IMAGE = document.querySelector('[data-id="active"]')
|
||||
let backoff = false
|
||||
// set defaults
|
||||
const config = {
|
||||
idle: '',
|
||||
active: '',
|
||||
url: 'ws://localhost:4455',
|
||||
password: '',
|
||||
sources: [],
|
||||
brightness: 50,
|
||||
height: 1,
|
||||
time: 0.2,
|
||||
}
|
||||
var sources = []
|
||||
IMAGE.style.transitionTimingFunction = 'ease-out'
|
||||
// register ontransitionend function for active image
|
||||
IMAGE.ontransitionend = async function () {
|
||||
if (IMAGE.style.transitionTimingFunction == 'ease-out') {
|
||||
IMAGE.style.transitionTimingFunction = 'ease-in'
|
||||
} else {
|
||||
IMAGE.style.transitionTimingFunction = 'ease-out'
|
||||
}
|
||||
if (IMAGE.style.bottom == '0vh') {
|
||||
backoff = false
|
||||
} else {
|
||||
IMAGE.style.bottom = '0vh'
|
||||
}
|
||||
}
|
||||
// register oninputvolumemeters for obs
|
||||
obs.addListener('InputVolumeMeters', function (data) {
|
||||
// construct event object
|
||||
const inputs = data.inputs
|
||||
const event = {}
|
||||
for (const input of inputs) {
|
||||
event[input.inputName] = input.inputLevelsMul
|
||||
}
|
||||
// remove sources that no longer exist
|
||||
for (const source of sources) {
|
||||
if (!source in event) {
|
||||
sources.splice(sources.indexOf(source), 1)
|
||||
broadcastChannel.postMessage('remove ' + source)
|
||||
}
|
||||
}
|
||||
// add newly created sources
|
||||
for (const source in event) {
|
||||
if (!sources.includes(source)) {
|
||||
sources.push(source)
|
||||
//console.log(source)
|
||||
broadcastChannel.postMessage('add ' + source)
|
||||
}
|
||||
}
|
||||
// if the selected source is missing, exit function
|
||||
if (!config.source in event) {
|
||||
return
|
||||
}
|
||||
// get the audio level of the selected sources
|
||||
/**
|
||||
* AUDIO LEVEL FORMAT
|
||||
* event[source][channel][?]=float
|
||||
* each channel contains an array of 3 floats
|
||||
*/
|
||||
let level = 0
|
||||
for (const source of config.sources) {
|
||||
level += event[source][0][0] || 0
|
||||
}
|
||||
// animate the image if neccessary
|
||||
if (backoff) {
|
||||
return
|
||||
}
|
||||
if (level != 0) {
|
||||
if (IMAGE.style.opacity == 1) {
|
||||
return
|
||||
}
|
||||
if (config.time != 0) {
|
||||
backoff = true
|
||||
}
|
||||
IMAGE.style.opacity = 1
|
||||
SIMAGE.style.opacity = 0
|
||||
IMAGE.style.bottom = config.height + 'vh'
|
||||
} else {
|
||||
SIMAGE.style.opacity = 1
|
||||
IMAGE.style.opacity = 0
|
||||
}
|
||||
})
|
||||
// register onmessage function for broadcastChannel
|
||||
broadcastChannel.onmessage = async function (event) {
|
||||
const args = event.data.split(' ')
|
||||
switch (args[0]) {
|
||||
case 'ping': {
|
||||
broadcastChannel.postMessage('pong')
|
||||
}
|
||||
case 'pong': { // Both overlay and dock are now online
|
||||
// trigger sync mechanism
|
||||
for (const id in config) {
|
||||
broadcastChannel.postMessage('set ' + id + ' ' + config[id])
|
||||
}
|
||||
for (const source of config.sources) {
|
||||
broadcastChannel.postMessage('enable ' + source)
|
||||
}
|
||||
sources = []
|
||||
break
|
||||
}
|
||||
case 'set': {
|
||||
const id = args[1]
|
||||
const value = args[2]
|
||||
for (const element of document.querySelectorAll('[data-id=' + id + ']')) {
|
||||
element.src = value
|
||||
}
|
||||
if (id == 'brightness') {
|
||||
SIMAGE.style.filter = 'brightness(' + config.brightness + '%)'
|
||||
}
|
||||
if (id == 'height') {
|
||||
IMAGE.style.height = 100 - config.height + 'vh'
|
||||
SIMAGE.style.height = 100 - config.height + 'vh'
|
||||
}
|
||||
if (id == 'time') {
|
||||
IMAGE.style.transitionDuration = value + 's'
|
||||
}
|
||||
if (id == 'password' || id == 'url') { // try to use new connection information
|
||||
//obs.disconnect()
|
||||
obs.connect(url = config.url, password = config.password, {
|
||||
eventSubscriptions: (1 << 16),
|
||||
})
|
||||
}
|
||||
localStorage.setItem(id, value)
|
||||
config[id] = value
|
||||
break
|
||||
}
|
||||
case 'get': {
|
||||
const id = args[1]
|
||||
const value = config[id]
|
||||
broadcastChannel.postMessage('set ' + id + ' ' + value)
|
||||
break
|
||||
}
|
||||
case 'enable': {
|
||||
const source = args[1]
|
||||
config.sources.push(source)
|
||||
localStorage.setItem('sources', JSON.stringify(config.sources))
|
||||
break
|
||||
}
|
||||
case 'disable': {
|
||||
const source = args[1]
|
||||
const index = config.sources.indexOf(source)
|
||||
config.sources.splice(index, 1)
|
||||
localStorage.setItem('sources', JSON.stringify(config.sources))
|
||||
break
|
||||
}
|
||||
default: {
|
||||
window.alert(JSUID + '-overlay did not understand this message:\n' + event.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sync config. If no value is stored, the default is used.
|
||||
// The resulting value is then sent to the dock (if loaded),
|
||||
// which may reject the value if user has already set a different one.
|
||||
// This mechanism creates a config priority of dock, storage, defaults.
|
||||
for (const id in config) {
|
||||
config[id] = await localStorage.getItem(id) || config[id]
|
||||
broadcastChannel.postMessage('set ' + id + ' ' + config[id])
|
||||
}
|
||||
config.sources = JSON.parse(await localStorage.getItem('sources')) || []
|
||||
const source=await localStorage.getItem('source')
|
||||
if(source){
|
||||
config.sources.push(source)
|
||||
localStorage.removeItem('source')
|
||||
}
|
||||
// apply config
|
||||
IMAGE.src = config.active
|
||||
IMAGE.style.height = 100 - config.height + 'vh'
|
||||
IMAGE.style.transitionDuration = config.time + 's'
|
||||
SIMAGE.src = config.idle
|
||||
SIMAGE.style.height = 100 - config.height + 'vh'
|
||||
SIMAGE.style.filter = 'brightness(' + config.brightness + '%)'
|
||||
// send startup completed ping
|
||||
broadcastChannel.postMessage('ping')
|
||||
// attempt obs connection
|
||||
obs.connect(url = config.url, password = config.password, {
|
||||
eventSubscriptions: (1 << 16),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||
"name": "pngtube",
|
||||
"assets": {"directory": "assets"},
|
||||
"workers_dev": false,
|
||||
"keep_vars": true,
|
||||
"compatibility_date": "2025-12-20",
|
||||
"build": {
|
||||
"watch_dir": "./src",
|
||||
"command": "esbuild --outdir=assets --format=esm --bundle --minify --sourcemap --sources-content src/*"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user