@@ -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 }}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Simple workflow for deploying static content to GitHub Pages
|
|
||||||
name: Deploy static content to Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Runs on pushes targeting the default branch
|
|
||||||
push:
|
|
||||||
branches: ["master"]
|
|
||||||
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
# Allow one concurrent deployment
|
|
||||||
concurrency:
|
|
||||||
group: "pages"
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Single deploy job since we're just deploying
|
|
||||||
deploy:
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
submodules: 'recursive'
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v2
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-pages-artifact@v1
|
|
||||||
with:
|
|
||||||
# Upload entire repository
|
|
||||||
path: '.'
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v1
|
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
/_*
|
node_modules
|
||||||
/*.pid
|
.wrangler
|
||||||
/*.log
|
|
||||||
/v4/yarn
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[submodule "js-util"]
|
|
||||||
path = js-util
|
|
||||||
url = git@github.com:sugoidogo/js-util.git
|
|
||||||
@@ -6,8 +6,6 @@ OBS Browser Source for creating an image based avatar that animates to audio inp
|
|||||||
- If it's under 27 (26 or lower), you need to update OBS
|
- 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 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
|
- If it's over 27 (28 or higher), you can skip this step
|
||||||
2. With OBS open, visit the [PNGTube Setup Page](https://sugoidogo.github.io/pngtube2/v8/) and authorize OBS.
|
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.
|
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.
|
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.
|
||||||
## Support
|
|
||||||
Support information for all my software is on my [GitHub profile](https://github.com/sugoidogo)
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 520 KiB After Width: | Height: | Size: 520 KiB |
|
Before Width: | Height: | Size: 521 KiB After Width: | Height: | Size: 521 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 423 KiB |
@@ -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>
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^4.56.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<body>
|
|
||||||
<div id="status">Loading...</div>
|
|
||||||
<span id="connectionSettings" hidden>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Websocket URL:</td>
|
|
||||||
<td><input id="url" type="url"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Websocket Password:</td>
|
|
||||||
<td><input id="password" type="password"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<button id="connect" onclick="connect()">Connect</button>
|
|
||||||
<button id="useQR" onclick="useQrCode()">Use QR Code</button>
|
|
||||||
<br>
|
|
||||||
<span id="qr" hidden>
|
|
||||||
<video id="qrvideo" hidden></video>
|
|
||||||
<br>
|
|
||||||
<span>Make sure this device and the one running OBS are on the same network.</span>
|
|
||||||
<br>
|
|
||||||
<button onclick="screenCapture()">Capture from Screen</button>
|
|
||||||
<button onclick="cameraCapture()">Capture from Camera</button>
|
|
||||||
<br>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span id="connected">
|
|
||||||
<div id="upgradePrompt" class="bordered" hidden>
|
|
||||||
<span id="name"></span>
|
|
||||||
<span> is using another version of PNGTube. Switch to this version?</span>
|
|
||||||
<button onclick="upgrade(this.parentElement.id)">Switch</button>
|
|
||||||
<button onclick="remove(this.parentElement.id)">Remove</button>
|
|
||||||
</div>
|
|
||||||
<span id="upgrades"></span>
|
|
||||||
<div id="settings" class="bordered" hidden>
|
|
||||||
<div id="name"></div>
|
|
||||||
<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>Audio Sources</td>
|
|
||||||
<td>
|
|
||||||
<select data-id="source"></select>
|
|
||||||
<button onclick="addSource(this.parentElement)">Add</button>
|
|
||||||
<ul>
|
|
||||||
<li hidden>
|
|
||||||
<button onclick="this.parentElement.remove()">Remove</button>
|
|
||||||
<button onclick="addGate(this.parentElement)" hidden id="noiseGate">Add Noise Gate</button>
|
|
||||||
<label data-id="source"></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>
|
|
||||||
<button onclick="save(this.parentElement.id)">Save and Apply</button>
|
|
||||||
<button onclick="remove(this.parentElement.id)">Remove</button>
|
|
||||||
</div>
|
|
||||||
<span id="inputSettings"></span>
|
|
||||||
<span id="newInput">
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Scene:</td>
|
|
||||||
<td><select id="scene"></select></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Name:</td>
|
|
||||||
<td><input type="text" id="newName"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<button onclick="addNew()">Add new PNGTube</button>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</body>
|
|
||||||
<style>
|
|
||||||
.bordered {
|
|
||||||
border: 1px solid black;
|
|
||||||
}
|
|
||||||
video {
|
|
||||||
max-width: 100vw;
|
|
||||||
max-height: 50vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script
|
|
||||||
src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.1/dist/obs-ws.min.js"
|
|
||||||
integrity="sha256-UbkntZrYTokZf8tldcF9nmOn5CNgcmDP+MNsUcKUpEE="
|
|
||||||
crossorigin="anonymous">
|
|
||||||
</script>
|
|
||||||
<script
|
|
||||||
src="https://cdn.jsdelivr.net/npm/qr-scanner@1.4.1/qr-scanner.umd.min.js"
|
|
||||||
integrity="sha256-jstRoCLjzwXwLN2o0DMkkwRHtqYCQMxrRkktAHwZekU="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
console.info('loading settings or defaults')
|
|
||||||
document.querySelector('#url').value=localStorage.getItem('url') || 'ws://localhost:4455'
|
|
||||||
document.querySelector('#password').value=localStorage.getItem('password') || ''
|
|
||||||
|
|
||||||
console.info('initializing obs-websocket-js')
|
|
||||||
const obs = new OBSWebSocket()
|
|
||||||
obs.addListener('ConnectionClosed',function onConnectionClosed(error){
|
|
||||||
document.querySelector('#connected').hidden=true
|
|
||||||
document.querySelector('#connectionSettings').hidden=false
|
|
||||||
switch(error.code){
|
|
||||||
case 1006:
|
|
||||||
document.querySelector('#status').innerHTML='obs websocket not found.<br>'+
|
|
||||||
'Is <a href="https://github.com/obsproject/obs-websocket/releases/latest">obs-websocket</a> installed?<br>'+
|
|
||||||
'Is obs open? Check your websocket settings in OBS under the Tools menu.'
|
|
||||||
break
|
|
||||||
case 4009:
|
|
||||||
document.querySelector('#status').innerHTML=error.message.split(' ').slice(-3).join(' ').toLowerCase().replace('authentication','Password')+
|
|
||||||
'<br>You can view or change your websocket password in OBS under the Tools menu.'
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
document.querySelector('#status').innerHTML='connection closed: Error '+error.code+': '+error.message
|
|
||||||
break
|
|
||||||
}
|
|
||||||
console.warn('connection closed', error.code, error.message)
|
|
||||||
document.querySelector('#upgrades').innerHTML=''
|
|
||||||
document.querySelector('#inputSettings').innerHTML=''
|
|
||||||
document.querySelector('select').innerHTML=''
|
|
||||||
})
|
|
||||||
obs.addListener('ConnectionOpened',function onConnectionOpened(){
|
|
||||||
console.info('connected to socket, saving url')
|
|
||||||
localStorage.setItem('url',document.querySelector('#url').value)
|
|
||||||
document.querySelector('#connectionSettings').hidden=true
|
|
||||||
})
|
|
||||||
obs.addListener('Identified',async function onIdentified(){
|
|
||||||
console.info('authentication passed, saving password')
|
|
||||||
localStorage.setItem('password',document.querySelector('#password').value)
|
|
||||||
const video=document.querySelector('#qrvideo')
|
|
||||||
if(video.srcObject!=null){
|
|
||||||
video.srcObject.getTracks().forEach(function stopCapture(track){
|
|
||||||
track.stop()
|
|
||||||
})
|
|
||||||
video.srcObject=null
|
|
||||||
}
|
|
||||||
document.querySelector('#status').innerHTML='Loading OBS data...'
|
|
||||||
const loadingPromises=[]
|
|
||||||
console.info('getting input list')
|
|
||||||
loadingPromises.push(obs.call('GetInputList',{}).then(function onReceiveBrowserInputList(response){
|
|
||||||
console.info('got input list:',response)
|
|
||||||
const inputPromises=[]
|
|
||||||
console.info('getting input settings')
|
|
||||||
for(const input of response.inputs){
|
|
||||||
inputPromises.push(obs.call('GetInputSettings',{inputName:input.inputName}).then(function onReceiveBrowserInputSettings(response){
|
|
||||||
Object.assign(input,response)
|
|
||||||
checkInput(input)
|
|
||||||
return input
|
|
||||||
}).catch(function onParseError(error){
|
|
||||||
console.error(input.inputName,error)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
loadingPromises.push(Promise.all(inputPromises).then(function onBrowserInputsFiltered(data){
|
|
||||||
console.info('got input settings:',data)
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
console.info('getting scene list')
|
|
||||||
loadingPromises.push(obs.call('GetSceneList',{}).then(function onReceiveSceneList(response){
|
|
||||||
console.info('got scene list:',response)
|
|
||||||
const scenes=document.querySelector('#scene')
|
|
||||||
for(const scene of response.scenes){
|
|
||||||
const option=document.createElement('option')
|
|
||||||
option.value=scene.sceneName
|
|
||||||
option.innerHTML=scene.sceneName
|
|
||||||
scenes.appendChild(option)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
while(loadingPromises.length!=0){
|
|
||||||
await loadingPromises.shift()
|
|
||||||
}
|
|
||||||
console.info('loading finished')
|
|
||||||
document.querySelector('#status').innerHTML='Ready'
|
|
||||||
document.querySelector('#connected').hidden=false
|
|
||||||
})
|
|
||||||
obs.addListener('InputCreated',checkInput)
|
|
||||||
obs.addListener('InputRemoved',onInputRemoved)
|
|
||||||
obs.addListener('InputNameChanged',function onInputNameChanged(input){
|
|
||||||
console.info('input name changed',input)
|
|
||||||
const element=document.querySelector('#'+input.oldInputName)
|
|
||||||
element.id=input.inputName
|
|
||||||
element.querySelector('#name').innerHTML=input.inputName
|
|
||||||
obs.call('GetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev5'+input.oldInputName
|
|
||||||
}).then(function(data){
|
|
||||||
obs.call('SetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev5'+input.oldInputName,
|
|
||||||
slotValue:{}
|
|
||||||
})
|
|
||||||
obs.call('SetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev5'+input.inputName,
|
|
||||||
slotValue:data.slotValue
|
|
||||||
}).then(function(){
|
|
||||||
setURL(input.inputName)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
console.info('connecting to obs')
|
|
||||||
connect()
|
|
||||||
|
|
||||||
function log(...data){
|
|
||||||
console.log(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connect(){
|
|
||||||
document.querySelector('#status').innerHTML='Connecting to OBS...'
|
|
||||||
try{
|
|
||||||
await obs.connect(
|
|
||||||
document.querySelector('#url').value,
|
|
||||||
document.querySelector('#password').value
|
|
||||||
)
|
|
||||||
}catch(e){
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function useQrCode(){
|
|
||||||
document.querySelector('#qr').hidden=false
|
|
||||||
}
|
|
||||||
|
|
||||||
let interval
|
|
||||||
|
|
||||||
function startQr(videoSource){
|
|
||||||
const video=document.querySelector('#qrvideo')
|
|
||||||
video.srcObject=videoSource
|
|
||||||
video.play()
|
|
||||||
video.hidden=false
|
|
||||||
interval=setInterval(qrScan,1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function screenCapture(){
|
|
||||||
const mediaSource=await navigator.mediaDevices.getDisplayMedia()
|
|
||||||
startQr(mediaSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cameraCapture(){
|
|
||||||
const videoSource=await navigator.mediaDevices.getUserMedia({video:true})
|
|
||||||
startQr(videoSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
function qrScan(){
|
|
||||||
const video=document.querySelector('#qrvideo')
|
|
||||||
QrScanner.scanImage(video,{returnDetailedScanResult:true})
|
|
||||||
.then(onQrScan).catch(function(error){console.warn(error)})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onQrScan(result){
|
|
||||||
var data=result.data.split('/')
|
|
||||||
if(data.shift()!='obsws:'){
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
clearInterval(interval)
|
|
||||||
data.shift()
|
|
||||||
const fullurl='ws://'+data.shift()
|
|
||||||
const port=fullurl.split(':').pop()
|
|
||||||
const localurl='ws://localhost:'+port
|
|
||||||
const password=data.shift()
|
|
||||||
const obsurl=document.querySelector('#url')
|
|
||||||
const obspassword=document.querySelector('#password')
|
|
||||||
obspassword.value=password
|
|
||||||
obsurl.value=localurl
|
|
||||||
if(await connect()){
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
obsurl.value=fullurl
|
|
||||||
if(await connect()){
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
interval=setInterval(qrScan,1000)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOverlayURL(name=''){
|
|
||||||
const url=new URL(window.location.href+'overlay.html')
|
|
||||||
const searchParams=url.searchParams
|
|
||||||
const obsurl=document.querySelector('#url')
|
|
||||||
const obspassword=document.querySelector('#password')
|
|
||||||
searchParams.set('name',name)
|
|
||||||
searchParams.set('obsurl',obsurl.value)
|
|
||||||
if(obspassword.value){
|
|
||||||
searchParams.set('obspassword',obspassword.value)
|
|
||||||
}
|
|
||||||
return url.href
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setURL(name,currentURL=getOverlayURL()){
|
|
||||||
const url=new URL(currentURL)
|
|
||||||
const searchParams=url.searchParams
|
|
||||||
const obsurl=document.querySelector('#url')
|
|
||||||
const obspassword=document.querySelector('#password')
|
|
||||||
if(searchParams.get('name')!=name){
|
|
||||||
searchParams.set('name',name)
|
|
||||||
}
|
|
||||||
if(searchParams.get('obsurl')!=obsurl.value){
|
|
||||||
searchParams.set('obsurl',obsurl.value)
|
|
||||||
}
|
|
||||||
if(searchParams.get('obspassword')!=obspassword.value){
|
|
||||||
searchParams.set('obspassword',obspassword.value)
|
|
||||||
}
|
|
||||||
if(currentURL!=url.href){
|
|
||||||
console.log('updating url for '+name)
|
|
||||||
obs.call('SetInputSettings',{
|
|
||||||
inputName:name,
|
|
||||||
inputSettings:{url:url.href}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLegacy(name){
|
|
||||||
console.info('adding upgrade prompt for '+name)
|
|
||||||
const upgradePrompt=document.querySelector('#upgradePrompt').cloneNode(true)
|
|
||||||
upgradePrompt.hidden=false
|
|
||||||
upgradePrompt.querySelector('#name').innerHTML=name
|
|
||||||
upgradePrompt.id=name
|
|
||||||
document.querySelector('#upgrades').appendChild(upgradePrompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings={}
|
|
||||||
|
|
||||||
function addSettings(name){
|
|
||||||
console.info('getting settings for',name)
|
|
||||||
const settings=document.querySelector('#settings').cloneNode(true)
|
|
||||||
settings.hidden=false
|
|
||||||
settings.querySelector('#name').innerHTML=name
|
|
||||||
settings.id=name
|
|
||||||
obs.call('GetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev5'+name
|
|
||||||
}).then(function(data){
|
|
||||||
data=data.slotValue
|
|
||||||
console.log('got settings for ',name,data)
|
|
||||||
if(data==null){
|
|
||||||
data={
|
|
||||||
idle:'',
|
|
||||||
active:'',
|
|
||||||
source:[],
|
|
||||||
brightness:50,
|
|
||||||
height:1,
|
|
||||||
time:0.2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
settings[name]=data
|
|
||||||
for(const input of settings.querySelectorAll('input[type=range],input[type=number]')){
|
|
||||||
const id=input.dataset.id
|
|
||||||
let input2
|
|
||||||
if(input.type=='range'){
|
|
||||||
input2=settings.querySelector('input[type=number][data-id='+id+']')
|
|
||||||
}else{
|
|
||||||
input2=settings.querySelector('input[type=range][data-id='+id+']')
|
|
||||||
}
|
|
||||||
input.oninput=function(){
|
|
||||||
input2.value=input.value
|
|
||||||
}
|
|
||||||
input.value=data[id]
|
|
||||||
}
|
|
||||||
for(const source of data.source){
|
|
||||||
addSource(settings,source)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
document.querySelector('#inputSettings').appendChild(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
function upgrade(name){
|
|
||||||
document.querySelector('#'+name).remove()
|
|
||||||
setURL(name)
|
|
||||||
addSettings(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(name){
|
|
||||||
const input={inputName:name}
|
|
||||||
obs.call('RemoveInput',input).then(()=>onInputRemoved(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
function addNew(){
|
|
||||||
const scene=document.querySelector('#scene').value
|
|
||||||
const name=document.querySelector('#newName').value
|
|
||||||
obs.call('CreateInput',{
|
|
||||||
sceneName:scene,
|
|
||||||
inputName:name,
|
|
||||||
inputKind:'browser_source',
|
|
||||||
inputSettings:{
|
|
||||||
url:getOverlayURL(name)
|
|
||||||
}
|
|
||||||
}).catch(function onCreateInputError(error){
|
|
||||||
document.querySelector('#status').innerHTML='Adding PNGTube failed: Error '+error.code+': '+error.message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkInput(input){
|
|
||||||
const option=document.createElement('option')
|
|
||||||
option.value=input.inputName
|
|
||||||
option.innerHTML=input.inputName
|
|
||||||
for(const select of document.querySelectorAll('select[data-id=source]')){
|
|
||||||
select.appendChild(option)
|
|
||||||
}
|
|
||||||
if(input.inputKind!='browser_source'){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if('url' in input.inputSettings === false){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if(input.inputSettings.url.startsWith(window.location.href)){
|
|
||||||
setURL(input.inputName,input.inputSettings.url)
|
|
||||||
addSettings(input.inputName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if(input.inputSettings.url.startsWith('https://sugoidogo.github.io/pngtube')){
|
|
||||||
addLegacy(input.inputName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function genericError(error,prefix=''){
|
|
||||||
console.log(error)
|
|
||||||
document.querySelector('#status').innerHTML=prefix+'Error '+error.code+': '+error.message
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInputRemoved(input){
|
|
||||||
console.info('input removed',input)
|
|
||||||
document.querySelector('#'+input.inputName).remove()
|
|
||||||
for(const option of document.querySelectorAll('option[value='+input.inputName+']')){
|
|
||||||
option.remove()
|
|
||||||
}
|
|
||||||
obs.call('SetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev5'+input.inputName,
|
|
||||||
slotValue:{}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(name){
|
|
||||||
const settings=document.querySelector('#'+name)
|
|
||||||
const data=settings[name]
|
|
||||||
for(const input of settings.querySelectorAll('input[type=number]')){
|
|
||||||
data[input.dataset.id]=input.value
|
|
||||||
}
|
|
||||||
data.source=[]
|
|
||||||
for(const source of settings.querySelectorAll('label[data-id=source]')){
|
|
||||||
if(source.innerHTML){
|
|
||||||
data.source.push(source.innerHTML)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const files=[]
|
|
||||||
for(const image of settings.querySelectorAll('input[type=file]')){
|
|
||||||
for(const file of image.files){
|
|
||||||
files.push(new Promise(function(resolve){
|
|
||||||
const id=image.dataset.id
|
|
||||||
const fileReader = new FileReader()
|
|
||||||
fileReader.onload = function () {
|
|
||||||
data[id]=fileReader.result
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
fileReader.readAsDataURL(file)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Promise.all(files).then(function(){
|
|
||||||
console.log('saving data:',data)
|
|
||||||
obs.call('SetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev5'+name,
|
|
||||||
slotValue:data
|
|
||||||
}).then(function(){
|
|
||||||
obs.call('SetInputSettings',{
|
|
||||||
inputName:name,
|
|
||||||
inputSettings:{url:''}
|
|
||||||
}).then(function(){
|
|
||||||
obs.call('SetInputSettings',{
|
|
||||||
inputName:name,
|
|
||||||
inputSettings:{url:getOverlayURL(name)}
|
|
||||||
}).catch(genericError)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
let width=0
|
|
||||||
let height=0
|
|
||||||
let imagePromises=[]
|
|
||||||
for(const source of [data.active,data.idle]){
|
|
||||||
imagePromises.push(new Promise(function(resolve){
|
|
||||||
const image=new Image();
|
|
||||||
image.onload=function(){
|
|
||||||
if(image.naturalWidth>width){
|
|
||||||
width=image.naturalWidth
|
|
||||||
}
|
|
||||||
if(image.naturalHeight>height){
|
|
||||||
height=image.naturalHeight
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
image.src=source
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
Promise.all(imagePromises).then(function(){
|
|
||||||
obs.call('SetInputSettings',{
|
|
||||||
inputName:name,
|
|
||||||
inputSettings:{
|
|
||||||
width:width,
|
|
||||||
height:height
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSource(parentElement,value=null){
|
|
||||||
if(value==null){
|
|
||||||
value=parentElement.querySelector('select').value
|
|
||||||
}
|
|
||||||
const listItem=parentElement.querySelector('li').cloneNode(true)
|
|
||||||
listItem.hidden=false
|
|
||||||
listItem.querySelector('label').innerHTML=value
|
|
||||||
parentElement.querySelector('ul').appendChild(listItem)
|
|
||||||
obs.call('GetSourceFilterList',{
|
|
||||||
sourceName:value
|
|
||||||
}).then(function(data){
|
|
||||||
for(const filter of data.filters){
|
|
||||||
if(filter.filterKind=='noise_gate_filter'){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
listItem.querySelector('#noiseGate').hidden=false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function addGate(parentElement){
|
|
||||||
const name=parentElement.querySelector('label').innerHTML
|
|
||||||
obs.call('CreateSourceFilter',{
|
|
||||||
sourceName:name,
|
|
||||||
filterName:'Noise Gate',
|
|
||||||
filterKind:'noise_gate_filter',
|
|
||||||
filterSettings:{
|
|
||||||
attack_time:25,
|
|
||||||
release_time:50,
|
|
||||||
close_threshold:-60,
|
|
||||||
open_threshold:-55
|
|
||||||
}
|
|
||||||
}).then(function(){
|
|
||||||
parentElement.querySelector('#noiseGate').hidden=true
|
|
||||||
obs.call('OpenInputFiltersDialog',{inputName:name})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
@@ -1,109 +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.jsdelivr.net/npm/obs-websocket-js@5.0.1/dist/obs-ws.min.js"
|
|
||||||
integrity="sha256-UbkntZrYTokZf8tldcF9nmOn5CNgcmDP+MNsUcKUpEE="
|
|
||||||
crossorigin="anonymous">
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
window.onload = async function () {
|
|
||||||
// set persistant variables
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
const obs = new OBSWebSocket()
|
|
||||||
const SIMAGE = document.querySelector('[data-id="idle"]')
|
|
||||||
const IMAGE = document.querySelector('[data-id="active"]')
|
|
||||||
let backoff = false
|
|
||||||
IMAGE.style.transitionTimingFunction = 'ease-out'
|
|
||||||
// attempt obs connection
|
|
||||||
await obs.connect(url = params.get('obsurl'), password = params.get('obspassword'), {
|
|
||||||
eventSubscriptions: (1 << 16),
|
|
||||||
})
|
|
||||||
// get config
|
|
||||||
const config=(await obs.call('GetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev5'+params.get('name')
|
|
||||||
})).slotValue
|
|
||||||
console.log(config)
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
// 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.source) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// 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 + '%)'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@import url('basic.css');
|
|
||||||
|
|
||||||
body {
|
|
||||||
--blink-min: 1;
|
|
||||||
--blink-max: 10;
|
|
||||||
--blink-time: 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#active-blink.blink.active,
|
|
||||||
#idle-blink.blink:not(.active) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
(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)
|
|
||||||
})()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
@import url('basic.css');
|
|
||||||
|
|
||||||
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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@import url('basic.css');
|
|
||||||
|
|
||||||
#idle, #idle-blink {
|
|
||||||
filter: brightness(50%);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<div>In this repo there are a few basic css files you can use as starting points.</div>
|
|
||||||
<div>PNGTube uses the name of each image for its id (the # items in css) and applies the 'active' class (the . items in css) when audio is detected.</div>
|
|
||||||
<div>You can replicate this behavior in the test links by clicking on the window to toggle the active class.</div>
|
|
||||||
<a href="basic.css" download>basic.css</a>
|
|
||||||
<a href="test.html?css=basic.css" target="_blank">test</a>
|
|
||||||
<span>All other files are based on this one, which changes the opacity of the 'active' and 'idle' images based on voice activity</span>
|
|
||||||
<br>
|
|
||||||
<a href="brightness.css" download>brightness.css</a>
|
|
||||||
<a href="test.html?css=brightness.css" target="_blank">test</a>
|
|
||||||
<span>This file lowers the brightness of the idle image</span>
|
|
||||||
<br>
|
|
||||||
<a href="bounce.css" download>bounce.css</a>
|
|
||||||
<a href="test.html?css=bounce.css" target="_blank">test</a>
|
|
||||||
<span>This file adds a bounce animation when speaking starts</span>
|
|
||||||
<br>
|
|
||||||
<a href="blink.css" download>blink.css</a>
|
|
||||||
<a href="test.html?css=blink.css" target="_blank">test</a>
|
|
||||||
<span>This file adds a blink animation, and requires active-blink and idle-blink images.</span>
|
|
||||||
<br>
|
|
||||||
<a href="reactive.css" download>reactive.ss</a>
|
|
||||||
<a href="test.html?css=reactive.css" target="_blank">test</a>
|
|
||||||
<span>This file simply combines the brightness and bounce files to replicate the <a href="https://reactive.fugi.tech/" target="_blank">Reactive by FugiTech</a> style.</span>
|
|
||||||
<br>
|
|
||||||
<a href="hide.css" doanload>hide.css</a>
|
|
||||||
<a href="test.html?css=hide.css" target="_blank">test</a>
|
|
||||||
<span>This file hides all images below the viewport during silence</span>
|
|
||||||
<br>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
@import url('basic.css');
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<body>
|
|
||||||
<div id="status">Loading...</div>
|
|
||||||
<span id="connectionSettings" hidden>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Websocket URL:</td>
|
|
||||||
<td><input id="url" type="url"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Websocket Password:</td>
|
|
||||||
<td><input id="password" type="password"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<button id="connect" onclick="connect()">Connect</button>
|
|
||||||
<button id="useQR" onclick="useQrCode()">Use QR Code</button>
|
|
||||||
<br>
|
|
||||||
<span id="qr" hidden>
|
|
||||||
<video id="qrvideo" hidden></video>
|
|
||||||
<br>
|
|
||||||
<span>Make sure this device and the one running OBS are on the same network.</span>
|
|
||||||
<br>
|
|
||||||
<button onclick="screenCapture()">Capture from Screen</button>
|
|
||||||
<button onclick="cameraCapture()">Capture from Camera</button>
|
|
||||||
<br>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span id="connected">
|
|
||||||
<div id="upgradePrompt" class="bordered" hidden>
|
|
||||||
<span id="name"></span>
|
|
||||||
<span> is using another version of PNGTube. Switch to this version?</span>
|
|
||||||
<button onclick="upgrade(this.parentElement.id)">Switch</button>
|
|
||||||
<button onclick="remove(this.parentElement.id)">Remove</button>
|
|
||||||
</div>
|
|
||||||
<span id="upgrades"></span>
|
|
||||||
<div id="settings" class="bordered" hidden>
|
|
||||||
<div id="name"></div>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Images</td>
|
|
||||||
<td>
|
|
||||||
<input type="file" accept="image/*" multiple onchange="addImage(this)">
|
|
||||||
<ul id="images">
|
|
||||||
<li hidden id="imageTemplate">
|
|
||||||
<button onclick="this.parentElement.remove()">Remove</button>
|
|
||||||
<span id="name"></span>
|
|
||||||
<img hidden>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr id="sources">
|
|
||||||
<td>Audio Sources</td>
|
|
||||||
<td>
|
|
||||||
<select data-id="source"></select>
|
|
||||||
<button onclick="addSource(this.parentElement)">Add</button>
|
|
||||||
<ul>
|
|
||||||
<li hidden>
|
|
||||||
<button onclick="this.parentElement.remove()">Remove</button>
|
|
||||||
<button onclick="addGate(this.parentElement)" hidden id="noiseGate">Add Noise Gate</button>
|
|
||||||
<label data-id="source"></label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>CSS</td>
|
|
||||||
<td>
|
|
||||||
<input id="css" type="file" accept="text/css" onchange="addCSS('#name')">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<button onclick="save(this.parentElement.id)">Save and Apply</button>
|
|
||||||
<button onclick="remove(this.parentElement.id)">Remove</button>
|
|
||||||
</div>
|
|
||||||
<span id="inputSettings"></span>
|
|
||||||
<span id="newInput">
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Scene:</td>
|
|
||||||
<td><select id="scene"></select></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Name:</td>
|
|
||||||
<td><input type="text" id="newName"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<button onclick="addNew()">Add new PNGTube</button>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</body>
|
|
||||||
<style>
|
|
||||||
.bordered {
|
|
||||||
border: 1px solid black;
|
|
||||||
}
|
|
||||||
video {
|
|
||||||
max-width: 100vw;
|
|
||||||
max-height: 50vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script
|
|
||||||
src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.1/dist/obs-ws.min.js"
|
|
||||||
integrity="sha256-UbkntZrYTokZf8tldcF9nmOn5CNgcmDP+MNsUcKUpEE="
|
|
||||||
crossorigin="anonymous">
|
|
||||||
</script>
|
|
||||||
<script
|
|
||||||
src="https://cdn.jsdelivr.net/npm/qr-scanner@1.4.1/qr-scanner.umd.min.js"
|
|
||||||
integrity="sha256-jstRoCLjzwXwLN2o0DMkkwRHtqYCQMxrRkktAHwZekU="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
console.info('loading settings or defaults')
|
|
||||||
document.querySelector('#url').value=localStorage.getItem('url') || 'ws://localhost:4455'
|
|
||||||
document.querySelector('#password').value=localStorage.getItem('password') || ''
|
|
||||||
|
|
||||||
let browser_source
|
|
||||||
|
|
||||||
console.info('initializing obs-websocket-js')
|
|
||||||
const obs = new OBSWebSocket()
|
|
||||||
obs.addListener('ConnectionClosed',function onConnectionClosed(error){
|
|
||||||
document.querySelector('#connected').hidden=true
|
|
||||||
document.querySelector('#connectionSettings').hidden=false
|
|
||||||
switch(error.code){
|
|
||||||
case 1006:
|
|
||||||
document.querySelector('#status').innerHTML='obs websocket not found.<br>'+
|
|
||||||
'Is <a href="https://github.com/obsproject/obs-websocket/releases/latest">obs-websocket</a> installed?<br>'+
|
|
||||||
'Is obs open? Check your websocket settings in OBS under the Tools menu.'
|
|
||||||
break
|
|
||||||
case 4009:
|
|
||||||
document.querySelector('#status').innerHTML=error.message.split(' ').slice(-3).join(' ').toLowerCase().replace('authentication','Password')+
|
|
||||||
'<br>You can view or change your websocket password in OBS under the Tools menu.'
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
document.querySelector('#status').innerHTML='connection closed: Error '+error.code+': '+error.message
|
|
||||||
break
|
|
||||||
}
|
|
||||||
console.warn('connection closed', error.code, error.message)
|
|
||||||
document.querySelector('#upgrades').innerHTML=''
|
|
||||||
document.querySelector('#inputSettings').innerHTML=''
|
|
||||||
document.querySelector('select').innerHTML=''
|
|
||||||
})
|
|
||||||
obs.addListener('ConnectionOpened',function onConnectionOpened(){
|
|
||||||
console.info('connected to socket, saving url')
|
|
||||||
localStorage.setItem('url',document.querySelector('#url').value)
|
|
||||||
document.querySelector('#connectionSettings').hidden=true
|
|
||||||
})
|
|
||||||
obs.addListener('Identified',async function onIdentified(){
|
|
||||||
console.info('authentication passed, saving password')
|
|
||||||
localStorage.setItem('password',document.querySelector('#password').value)
|
|
||||||
const video=document.querySelector('#qrvideo')
|
|
||||||
if(video.srcObject!=null){
|
|
||||||
video.srcObject.getTracks().forEach(function stopCapture(track){
|
|
||||||
track.stop()
|
|
||||||
})
|
|
||||||
video.srcObject=null
|
|
||||||
}
|
|
||||||
document.querySelector('#status').innerHTML='Loading OBS data...'
|
|
||||||
const loadingPromises=[]
|
|
||||||
console.info('getting scene list')
|
|
||||||
loadingPromises.push(obs.call('GetSceneList',{}).then(function onReceiveSceneList(response){
|
|
||||||
console.info('got scene list:',response)
|
|
||||||
const scenes=document.querySelector('#scene')
|
|
||||||
for(const scene of response.scenes){
|
|
||||||
const option=document.createElement('option')
|
|
||||||
option.value=scene.sceneName
|
|
||||||
option.innerHTML=scene.sceneName
|
|
||||||
scenes.appendChild(option)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
console.info('getting browser source type name')
|
|
||||||
loadingPromises.push(obs.call('GetInputKindList',{unversioned:true}).then(function onGetInputKindList(response){
|
|
||||||
for(const name of response.inputKinds){
|
|
||||||
if(name.includes('browser')){
|
|
||||||
browser_source=name
|
|
||||||
console.debug('browser_source='+name)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.warn('browser source type not found, defaulting to browser_source')
|
|
||||||
browser_source='browser_source'
|
|
||||||
}).then(()=>{
|
|
||||||
console.info('getting input list')
|
|
||||||
obs.call('GetInputList',{}).then(function onReceiveBrowserInputList(response){
|
|
||||||
console.info('got input list:',response)
|
|
||||||
const inputPromises=[]
|
|
||||||
console.info('getting input settings')
|
|
||||||
for(const input of response.inputs){
|
|
||||||
inputPromises.push(obs.call('GetInputSettings',{inputName:input.inputName}).then(function onReceiveBrowserInputSettings(response){
|
|
||||||
Object.assign(input,response)
|
|
||||||
checkInput(input)
|
|
||||||
return input
|
|
||||||
}).catch(function onParseError(error){
|
|
||||||
console.error(input.inputName,error)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
loadingPromises.push(Promise.all(inputPromises).then(function onBrowserInputsFiltered(data){
|
|
||||||
console.info('got input settings:',data)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
while(loadingPromises.length!=0){
|
|
||||||
await loadingPromises.shift()
|
|
||||||
}
|
|
||||||
console.info('loading finished')
|
|
||||||
document.querySelector('#status').innerHTML='Ready'
|
|
||||||
document.querySelector('#connected').hidden=false
|
|
||||||
})
|
|
||||||
obs.addListener('InputCreated',checkInput)
|
|
||||||
obs.addListener('InputRemoved',onInputRemoved)
|
|
||||||
obs.addListener('InputNameChanged',function onInputNameChanged(input){
|
|
||||||
console.info('input name changed',input)
|
|
||||||
const element=document.querySelector('#'+input.oldInputName)
|
|
||||||
element.id=input.inputName
|
|
||||||
element.querySelector('#name').innerHTML=input.inputName
|
|
||||||
})
|
|
||||||
|
|
||||||
console.info('connecting to obs')
|
|
||||||
connect()
|
|
||||||
|
|
||||||
function log(...data){
|
|
||||||
console.log(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connect(){
|
|
||||||
document.querySelector('#status').innerHTML='Connecting to OBS...'
|
|
||||||
try{
|
|
||||||
await obs.connect(
|
|
||||||
document.querySelector('#url').value,
|
|
||||||
document.querySelector('#password').value
|
|
||||||
)
|
|
||||||
}catch(e){
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function useQrCode(){
|
|
||||||
document.querySelector('#qr').hidden=false
|
|
||||||
}
|
|
||||||
|
|
||||||
let interval
|
|
||||||
|
|
||||||
function startQr(videoSource){
|
|
||||||
const video=document.querySelector('#qrvideo')
|
|
||||||
video.srcObject=videoSource
|
|
||||||
video.play()
|
|
||||||
video.hidden=false
|
|
||||||
interval=setInterval(qrScan,1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function screenCapture(){
|
|
||||||
const mediaSource=await navigator.mediaDevices.getDisplayMedia()
|
|
||||||
startQr(mediaSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cameraCapture(){
|
|
||||||
const videoSource=await navigator.mediaDevices.getUserMedia({video:true})
|
|
||||||
startQr(videoSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
function qrScan(){
|
|
||||||
const video=document.querySelector('#qrvideo')
|
|
||||||
QrScanner.scanImage(video,{returnDetailedScanResult:true})
|
|
||||||
.then(onQrScan).catch(function(error){console.warn(error)})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onQrScan(result){
|
|
||||||
var data=result.data.split('/')
|
|
||||||
if(data.shift()!='obsws:'){
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
clearInterval(interval)
|
|
||||||
data.shift()
|
|
||||||
const fullurl='ws://'+data.shift()
|
|
||||||
const port=fullurl.split(':').pop()
|
|
||||||
const localurl='ws://localhost:'+port
|
|
||||||
const password=data.shift()
|
|
||||||
const obsurl=document.querySelector('#url')
|
|
||||||
const obspassword=document.querySelector('#password')
|
|
||||||
obspassword.value=password
|
|
||||||
obsurl.value=localurl
|
|
||||||
if(await connect()){
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
obsurl.value=fullurl
|
|
||||||
if(await connect()){
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
interval=setInterval(qrScan,1000)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOverlayURL(name=''){
|
|
||||||
const length=window.location.href.lastIndexOf('/')
|
|
||||||
const url=new URL(window.location.href.substring(0,length+1)+'overlay.html')
|
|
||||||
const searchParams=url.searchParams
|
|
||||||
const obsurl=document.querySelector('#url')
|
|
||||||
const obspassword=document.querySelector('#password')
|
|
||||||
searchParams.set('name',name)
|
|
||||||
searchParams.set('obsurl',obsurl.value)
|
|
||||||
if(obspassword.value){
|
|
||||||
searchParams.set('obspassword',obspassword.value)
|
|
||||||
}
|
|
||||||
return url.href
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setURL(name,currentURL=getOverlayURL()){
|
|
||||||
const url=new URL(currentURL)
|
|
||||||
const searchParams=url.searchParams
|
|
||||||
const obsurl=document.querySelector('#url')
|
|
||||||
const obspassword=document.querySelector('#password')
|
|
||||||
if(searchParams.get('name')!=name){
|
|
||||||
searchParams.set('name',name)
|
|
||||||
}
|
|
||||||
if(searchParams.get('obsurl')!=obsurl.value){
|
|
||||||
searchParams.set('obsurl',obsurl.value)
|
|
||||||
}
|
|
||||||
if(searchParams.get('obspassword')!=obspassword.value){
|
|
||||||
searchParams.set('obspassword',obspassword.value)
|
|
||||||
}
|
|
||||||
if(currentURL!=url.href){
|
|
||||||
console.log('updating url for '+name)
|
|
||||||
obs.call('SetInputSettings',{
|
|
||||||
inputName:name,
|
|
||||||
inputSettings:{url:url.href}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLegacy(name){
|
|
||||||
console.info('adding upgrade prompt for '+name)
|
|
||||||
const upgradePrompt=document.querySelector('#upgradePrompt').cloneNode(true)
|
|
||||||
upgradePrompt.hidden=false
|
|
||||||
upgradePrompt.querySelector('#name').innerHTML=name
|
|
||||||
upgradePrompt.id=name
|
|
||||||
document.querySelector('#upgrades').appendChild(upgradePrompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
const allSettings={}
|
|
||||||
|
|
||||||
function addSettings(name){
|
|
||||||
console.info('getting settings for',name)
|
|
||||||
const settings=document.querySelector('#settings').cloneNode(true)
|
|
||||||
settings.hidden=false
|
|
||||||
settings.querySelector('#name').innerHTML=name
|
|
||||||
settings.id=name
|
|
||||||
const css=settings.querySelector('#css')
|
|
||||||
css.outerHTML=css.outerHTML.replace('#name',name)
|
|
||||||
obs.call('GetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev6'+name
|
|
||||||
}).then(function(data){
|
|
||||||
data=data.slotValue
|
|
||||||
console.log('got settings for',name,data)
|
|
||||||
if(data==null){
|
|
||||||
data={
|
|
||||||
image:{},
|
|
||||||
source:[],
|
|
||||||
css:''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allSettings[name]=data
|
|
||||||
for(const source of data.source){
|
|
||||||
addSource(settings.querySelector('#sources'),source)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
document.querySelector('#inputSettings').appendChild(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
function upgrade(name){
|
|
||||||
document.querySelector('#'+name).remove()
|
|
||||||
setURL(name)
|
|
||||||
addSettings(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(name){
|
|
||||||
const input={inputName:name}
|
|
||||||
obs.call('RemoveInput',input).then(()=>onInputRemoved(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
function addNew(){
|
|
||||||
const scene=document.querySelector('#scene').value
|
|
||||||
const name=document.querySelector('#newName').value
|
|
||||||
obs.call('CreateInput',{
|
|
||||||
sceneName:scene,
|
|
||||||
inputName:name,
|
|
||||||
inputKind:browser_source,
|
|
||||||
inputSettings:{
|
|
||||||
url:getOverlayURL(name)
|
|
||||||
}
|
|
||||||
}).catch(function onCreateInputError(error){
|
|
||||||
document.querySelector('#status').innerHTML='Adding PNGTube failed: Error '+error.code+': '+error.message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkInput(input){
|
|
||||||
const option=document.createElement('option')
|
|
||||||
option.value=input.inputName
|
|
||||||
option.innerHTML=input.inputName
|
|
||||||
for(const select of document.querySelectorAll('select[data-id=source]')){
|
|
||||||
select.appendChild(option)
|
|
||||||
}
|
|
||||||
if(input.inputKind!=browser_source){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if('url' in input.inputSettings === false){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if(input.inputSettings.url.startsWith(window.location.href)){
|
|
||||||
setURL(input.inputName,input.inputSettings.url)
|
|
||||||
addSettings(input.inputName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if(input.inputSettings.url.startsWith('https://sugoidogo.github.io/pngtube')){
|
|
||||||
addLegacy(input.inputName)
|
|
||||||
}
|
|
||||||
if(input.inputSettings.url.startsWith(window.location.href.split('/').slice(0,-2).join('/'))){
|
|
||||||
addLegacy(input.inputName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function genericError(error,prefix=''){
|
|
||||||
console.log(error)
|
|
||||||
document.querySelector('#status').innerHTML=prefix+'Error '+error.code+': '+error.message
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInputRemoved(input){
|
|
||||||
console.info('input removed',input)
|
|
||||||
document.querySelector('#'+input.inputName).remove()
|
|
||||||
for(const option of document.querySelectorAll('option[value='+input.inputName+']')){
|
|
||||||
option.remove()
|
|
||||||
}
|
|
||||||
obs.call('SetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev5'+input.inputName,
|
|
||||||
slotValue:{}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(name){
|
|
||||||
const settings=document.querySelector('#'+name)
|
|
||||||
const data=allSettings[name]
|
|
||||||
const sources=[]
|
|
||||||
for(const source of settings.querySelectorAll('label[data-id=source]')){
|
|
||||||
if(source.innerHTML){
|
|
||||||
sources.push(source.innerHTML)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(sources.length!=0){
|
|
||||||
data.source=sources
|
|
||||||
}
|
|
||||||
const images={}
|
|
||||||
for(const image of settings.querySelectorAll('#imageTemplate')){
|
|
||||||
const img=image.querySelector('img')
|
|
||||||
console.log(img)
|
|
||||||
if(img.src){
|
|
||||||
const name=image.querySelector('#name')
|
|
||||||
console.log(name.innerHTML)
|
|
||||||
images[name.innerHTML]=img.src
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(Object.keys(images).length>0){
|
|
||||||
data.image=images
|
|
||||||
}
|
|
||||||
console.log('saving data:',data)
|
|
||||||
obs.call('SetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev6'+name,
|
|
||||||
slotValue:data
|
|
||||||
})
|
|
||||||
let width=0
|
|
||||||
let height=0
|
|
||||||
let imagePromises=[]
|
|
||||||
for(const [name,source] of Object.entries(data.image)){
|
|
||||||
imagePromises.push(new Promise(function(resolve){
|
|
||||||
const image=new Image();
|
|
||||||
image.onload=function(){
|
|
||||||
if(image.naturalWidth>width){
|
|
||||||
width=image.naturalWidth
|
|
||||||
}
|
|
||||||
if(image.naturalHeight>height){
|
|
||||||
height=image.naturalHeight
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
image.src=source
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
Promise.all(imagePromises).then(function(){
|
|
||||||
obs.call('SetInputSettings',{
|
|
||||||
inputName:name,
|
|
||||||
inputSettings:{url:''}
|
|
||||||
}).then(function(){
|
|
||||||
obs.call('SetInputSettings',{
|
|
||||||
inputName:name,
|
|
||||||
inputSettings:{
|
|
||||||
url:getOverlayURL(name),
|
|
||||||
width:width,
|
|
||||||
height:height
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSource(parentElement,value=null){
|
|
||||||
if(value==null){
|
|
||||||
value=parentElement.querySelector('select').value
|
|
||||||
}
|
|
||||||
const listItem=parentElement.querySelector('li').cloneNode(true)
|
|
||||||
listItem.hidden=false
|
|
||||||
listItem.querySelector('label').innerHTML=value
|
|
||||||
parentElement.querySelector('ul').appendChild(listItem)
|
|
||||||
obs.call('GetSourceFilterList',{
|
|
||||||
sourceName:value
|
|
||||||
}).then(function(data){
|
|
||||||
for(const filter of data.filters){
|
|
||||||
if(filter.filterKind=='noise_gate_filter'){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
listItem.querySelector('#noiseGate').hidden=false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function addGate(parentElement){
|
|
||||||
const name=parentElement.querySelector('label').innerHTML
|
|
||||||
obs.call('CreateSourceFilter',{
|
|
||||||
sourceName:name,
|
|
||||||
filterName:'Noise Gate',
|
|
||||||
filterKind:'noise_gate_filter',
|
|
||||||
filterSettings:{
|
|
||||||
attack_time:25,
|
|
||||||
release_time:50,
|
|
||||||
close_threshold:-60,
|
|
||||||
open_threshold:-55
|
|
||||||
}
|
|
||||||
}).then(function(){
|
|
||||||
parentElement.querySelector('#noiseGate').hidden=true
|
|
||||||
obs.call('OpenInputFiltersDialog',{inputName:name})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function addImage(filePicker){
|
|
||||||
for(const file of filePicker.files){
|
|
||||||
const listItem=document.querySelector('#imageTemplate').cloneNode(true)
|
|
||||||
listItem.hidden=false
|
|
||||||
listItem.querySelector('#name').textContent=file.name.split('.').shift()
|
|
||||||
const fileReader=new FileReader()
|
|
||||||
fileReader.onload=function(){
|
|
||||||
listItem.querySelector('img').src=fileReader.result
|
|
||||||
filePicker.parentElement.querySelector('#images').appendChild(listItem)
|
|
||||||
}
|
|
||||||
fileReader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCSS(name){
|
|
||||||
const fileReader=new FileReader()
|
|
||||||
fileReader.onload=function(){
|
|
||||||
allSettings[name].css=fileReader.result
|
|
||||||
}
|
|
||||||
fileReader.readAsText(document.querySelector('#'+name).querySelector('#css').files[0])
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<script
|
|
||||||
src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.1/dist/obs-ws.min.js"
|
|
||||||
integrity="sha256-UbkntZrYTokZf8tldcF9nmOn5CNgcmDP+MNsUcKUpEE="
|
|
||||||
crossorigin="anonymous">
|
|
||||||
</script>
|
|
||||||
<script type="module" src="https://sugoidogo.github.io/js-util/CSSRandom.js"></script>
|
|
||||||
<script>
|
|
||||||
const obs=new OBSWebSocket()
|
|
||||||
const params=new URLSearchParams(window.location.search)
|
|
||||||
const obsurl=params.get('obsurl')
|
|
||||||
const obspassword=params.get('obspassword')
|
|
||||||
const name=params.get('name')
|
|
||||||
const inputs=[]
|
|
||||||
let backoff=false
|
|
||||||
let active=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
|
|
||||||
|
|
||||||
obs.addListener('Identified',function onIdentified(){
|
|
||||||
obs.call('GetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev6'+name
|
|
||||||
}).then(function onGetPersistentData(data){
|
|
||||||
data=data.slotValue
|
|
||||||
console.log(data)
|
|
||||||
const style=document.createElement('style')
|
|
||||||
style.innerHTML=data.css
|
|
||||||
const body=document.querySelector('body')
|
|
||||||
body.appendChild(style)
|
|
||||||
for(const [id,image] of Object.entries(data.image)){
|
|
||||||
const img=document.createElement('img')
|
|
||||||
img.id=id
|
|
||||||
img.src=image
|
|
||||||
body.appendChild(img)
|
|
||||||
}
|
|
||||||
for(const input of data.source){
|
|
||||||
inputs.push(input)
|
|
||||||
}
|
|
||||||
obs.on('InputVolumeMeters',function onInputVolumeMeters(data){
|
|
||||||
for(const input of data.inputs){
|
|
||||||
if(inputs.includes(input.inputName)){
|
|
||||||
for(const channel of input.inputLevelsMul){
|
|
||||||
for(const value of channel){
|
|
||||||
if(value!=0){
|
|
||||||
active=true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
active=false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
obs.addListener('InputNameChanged',function onInputNameChanged(input){
|
|
||||||
console.log(input)
|
|
||||||
obs.call('GetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev6'+input.oldInputName
|
|
||||||
}).then(function(data){
|
|
||||||
obs.call('SetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev6'+input.oldInputName,
|
|
||||||
slotValue:{
|
|
||||||
image:{},
|
|
||||||
source:[],
|
|
||||||
css:''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
obs.call('SetPersistentData',{
|
|
||||||
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
|
|
||||||
slotName:'pngtubev6'+input.inputName,
|
|
||||||
slotValue:data.slotValue
|
|
||||||
}).then(function(){
|
|
||||||
const url=new URL(window.location.href)
|
|
||||||
const searchParams=url.searchParams
|
|
||||||
searchParams.set('name',input.inputName)
|
|
||||||
obs.call('SetInputSettings',{
|
|
||||||
inputName:input.inputName,
|
|
||||||
inputSettings:{url:url.href}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
window.onload=async function(){
|
|
||||||
obs.connect(obsurl,obspassword,{eventSubscriptions:(1 << 16)+(1 << 3)})
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@import url('brightness.css');
|
|
||||||
@import url('bounce.css');
|
|
||||||
@import url('blink.css');
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<img id="active" src="../tubie/active.png">
|
|
||||||
<img id="active-blink" src="../tubie/active-blink.png">
|
|
||||||
<img id="idle" src="../tubie/idle.png">
|
|
||||||
<img id="idle-blink" src="../tubie/idle-blink.png">
|
|
||||||
<script>
|
|
||||||
const csslink=new URLSearchParams(window.location.search).get('css')
|
|
||||||
const css=document.createElement('link')
|
|
||||||
css.rel='stylesheet'
|
|
||||||
css.href=csslink
|
|
||||||
document.head.appendChild(css)
|
|
||||||
|
|
||||||
let backoff=false
|
|
||||||
let idle=false
|
|
||||||
|
|
||||||
window.onclick=async function(){
|
|
||||||
if(backoff){
|
|
||||||
if(!idle){
|
|
||||||
idle=true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for(const img of document.querySelectorAll('img')){
|
|
||||||
img.classList.toggle('active')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function backoffStart(event){
|
|
||||||
if(event instanceof AnimationEvent && getComputedStyle(event.target,event.pseudoElement).animationIterationCount=='infinite'){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
backoff=true
|
|
||||||
}
|
|
||||||
|
|
||||||
function backoffEnd(){
|
|
||||||
backoff=false
|
|
||||||
if(idle){
|
|
||||||
for(const img of document.querySelectorAll('img')){
|
|
||||||
img.classList.remove('active')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.ontransitionstart=backoffStart
|
|
||||||
window.ontransitionend=backoffEnd
|
|
||||||
window.ontransitioncancel=backoffEnd
|
|
||||||
window.onanimationstart=backoffStart
|
|
||||||
window.onanimationend=backoffEnd
|
|
||||||
window.ontransitioncancel=backoffEnd
|
|
||||||
|
|
||||||
import('./blink.js')
|
|
||||||
</script>
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
<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 & Copy URL</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>
|
|
||||||
* {
|
|
||||||
font-family: Inter, Roobert, Helvetica Neue, Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
input:invalid:required {
|
|
||||||
background-color: lightpink;
|
|
||||||
}
|
|
||||||
button#add_source,
|
|
||||||
button.sensitivity {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.js"
|
|
||||||
integrity="sha256-6fEA3uF0XCtisIcFZy5HTT58XocXY7LX7JUfEiK6W5Y="
|
|
||||||
crossorigin="anonymous">
|
|
||||||
</script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.2/dist/obs-ws.js"
|
|
||||||
integrity="sha256-WK517pkeDj2ugtVH+GZa/hQqIoNWNMcBk241/vo1mBw="
|
|
||||||
crossorigin="anonymous">
|
|
||||||
</script>
|
|
||||||
<script type="module">
|
|
||||||
import FileReaderAsync from '../js-util/FileReaderAsync.mjs'
|
|
||||||
import {getFormDataDeep} from '../js-util/FormDataDeep.mjs'
|
|
||||||
/** @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})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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.error(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://sugoidogo.github.io/obsconnect')
|
|
||||||
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('/')
|
|
||||||
}
|
|
||||||
url.searchParams.append('name',instance_select.value)
|
|
||||||
url.hash=JSON.stringify(config)
|
|
||||||
if(url.href.length>30000){
|
|
||||||
status_div.innerHTML="Too much data! That url probably won't work unless you use this page as a custom browser dock in OBS."
|
|
||||||
status_div.hidden=false
|
|
||||||
}
|
|
||||||
navigator.clipboard.writeText(url.href)
|
|
||||||
.catch(function(){
|
|
||||||
url.hash=''
|
|
||||||
status_div.innerHTML='Auto Copy Failed! Please manually copy the following url:<br>'
|
|
||||||
+url.href+'<br>'
|
|
||||||
status_div.hidden=false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
|
|
||||||
</script>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<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>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js"
|
|
||||||
integrity="sha256-zBaNlfuSfUaxBDcmz+E5mOCJAv9j8kMw4rsikBCe0UU="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.2/dist/obs-ws.min.js"
|
|
||||||
integrity="sha256-h/nnsIkJJZMNu08xQJBMuUvcrgcUj1YlTYmoA0q4Ep8="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script type="module">
|
|
||||||
/** @type {Storage} */
|
|
||||||
const localStorage = localforage.createInstance({ name: 'pngtube.v7' })
|
|
||||||
const search_params = new URLSearchParams(location.search)
|
|
||||||
async function getConfig(){
|
|
||||||
try {
|
|
||||||
const config=JSON.parse(decodeURIComponent(location.hash.substring(1)))
|
|
||||||
localStorage.setItem(search_params.get('name'),config)
|
|
||||||
return config
|
|
||||||
} catch(error) {
|
|
||||||
console.warn(error)
|
|
||||||
return localStorage.getItem(search_params.get('name'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// load config and assets
|
|
||||||
const config = await getConfig()
|
|
||||||
console.debug(config)
|
|
||||||
if (!(config.assets instanceof Array)) {
|
|
||||||
config.assets = [config.assets]
|
|
||||||
}
|
|
||||||
if(typeof config.sources == 'string'){
|
|
||||||
config.sources=[config.sources]
|
|
||||||
}
|
|
||||||
import loadAsset from '../js-util/AssetLoader.mjs'
|
|
||||||
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)})
|
|
||||||
</script>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#idle, #idle-blink {
|
|
||||||
filter: brightness(50%);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"assets":[
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "pngtube",
|
||||||
|
"assets": {"directory": "assets"},
|
||||||
|
"workers_dev": false,
|
||||||
|
"keep_vars": true,
|
||||||
|
"compatibility_date": "2025-12-20"
|
||||||
|
}
|
||||||