729 Commits

Author SHA1 Message Date
steveseguin
d48995c8f8 minor fixes 2026-01-25 19:22:49 -05:00
Steve Seguin
de54cd456a Merge pull request #1224 from pingval/develop
Update Japanese translation
2026-01-25 19:18:11 -05:00
pingval
90eda40006 Update Japanese translation 2026-01-24 20:47:42 +09:00
Steve Seguin
a226a52f34 tweak fr 2026-01-18 15:38:06 -05:00
steveseguin
c82e3d6418 more tranlsation files 2026-01-18 15:33:12 -05:00
steveseguin
c599d14c3b tip conflict fix 2026-01-18 13:39:12 -05:00
steveseguin
ff57c065ee clean up 2026-01-18 03:27:31 -05:00
steveseguin
a631bc074c v29.0 2026-01-18 03:27:00 -05:00
Steve Seguin
189b8350f2 Merge pull request #1223 from marcinmajsc/add-pl-translations
Add Polish translation
2026-01-18 02:54:54 -05:00
marcinmajsc
6162959292 Add pl.json
Make translations for Polish language
2026-01-09 02:26:05 +01:00
Steve Seguin
8a5cba72c3 Delete docs.md 2025-12-05 10:39:30 -05:00
Steve Seguin
aabf1bb560 remove docs 2025-12-05 10:38:31 -05:00
Steve Seguin
19984075f3 including new examples 2025-12-05 10:35:30 -05:00
steveseguin
c7be6d88d2 v28.5 update sync 2025-12-05 10:35:30 -05:00
Steve Seguin
f3d18ad3ee Merge pull request #1215 from MorseTheCode/develop-2
Fix parenthesis in zh-CN.json
2025-10-27 19:59:14 -04:00
MorseTheCode
54a16a324d Fix parenthesis in zh-CN.json 2025-10-27 20:56:02 -03:00
steveseguin
552c5cfc91 manifest fix 2025-10-22 02:00:37 -04:00
Steve Seguin
b078f20e35 Merge pull request #1214 from MorseTheCode/test-develop
Updating Obs Source Control Dock

thank you
2025-10-22 00:58:11 -04:00
MorseTheCode
4c6d4eb22b update obs dock 2025-10-22 01:45:17 -03:00
steveseguin
9e6cd6dc6f clean up 2025-10-21 22:17:57 -04:00
steveseguin
fa3df7a160 remove dead file 2025-10-21 21:32:38 -04:00
Steve Seguin
c71061f598 Add files via upload 2025-10-21 21:27:07 -04:00
Steve Seguin
6a1309879d Add files via upload
v28.4 updates

- minor css fixes
2025-10-21 21:24:01 -04:00
Steve Seguin
0071496903 Add files via upload 2025-10-21 21:06:01 -04:00
Steve Seguin
d6789af59b Add files via upload 2025-10-21 20:54:35 -04:00
Steve Seguin
2fa7f629be Add files via upload 2025-10-21 20:52:45 -04:00
Steve Seguin
20c815e607 Add files via upload
more security patches
2025-10-21 20:13:06 -04:00
Steve Seguin
83c0ac753a Add files via upload 2025-10-21 19:52:49 -04:00
Steve Seguin
0dbe6cfb7e Merge pull request #1213 from mijorus/develop
Add manifest.json to make the app installable on mobile devices
2025-10-21 14:37:22 -04:00
Lorenzo Paderi
d3bac99d70 added manifest.json 2025-10-21 17:21:31 +02:00
Steve Seguin
89cc7205a8 Updating the privacy policy and tos
Update README.md
2025-09-08 22:36:26 -04:00
Steve Seguin
5dbd999a9a Update README.md 2025-09-08 22:35:40 -04:00
steveseguin
1eeb025378 another css 2025-09-03 10:37:29 -04:00
steveseguin
ca3757ee75 css improvements 2025-09-03 10:34:53 -04:00
Steve Seguin
bd002bf135 Merge pull request #1206 from steveseguin/steveseguin-patch-1
Delete translations/default.json
2025-08-26 00:57:04 -04:00
Steve Seguin
728e198810 Delete translations/default.json 2025-08-26 00:53:53 -04:00
steveseguin
45f80890fa . 2025-08-26 00:49:40 -04:00
Steve Seguin
6ea075b67c Merge pull request #1204 from yigitgulyurt/patch-1
Update tr.json
2025-08-13 08:46:30 -04:00
yigitgulyurt
f3acd0877c Update tr.json
WIP: Added initial Turkish translations for UI strings; translation is still in progress / WIP: Kullanıcı arayüzü metinleri için ilk Türkçe çeviriler eklendi; çeviri hâlâ devam ediyor
2025-08-13 15:40:54 +03:00
Steve Seguin
97f016d723 Merge pull request #1197 from MorseTheCode/develop-2
Reduce Active Stream size
2025-06-07 20:08:35 -04:00
MorseTheCode
dc0e2d02c4 Update index.html 2025-06-07 20:57:51 -03:00
MorseTheCode
08dfceacdd Update index.html 2025-06-07 20:28:12 -03:00
MorseTheCode
eb21781b23 Update index.html 2025-06-07 20:20:59 -03:00
MorseTheCode
fea860ab90 Update index.html 2025-06-07 20:12:41 -03:00
MorseTheCode
cf8bf079fa Update index.html 2025-06-07 20:09:56 -03:00
MorseTheCode
c4a37895c8 Update index.html 2025-06-07 20:06:24 -03:00
MorseTheCode
976c7627fd Update index.html 2025-06-07 20:03:50 -03:00
MorseTheCode
8ee8d64619 Update index.html 2025-06-07 20:03:16 -03:00
MorseTheCode
c1b24bbc39 Update index.html 2025-06-07 19:40:56 -03:00
steveseguin
416daa2d8b delete problem files 2025-06-07 18:27:58 -04:00
steveseguin
67685d99e7 more langs for obs 2025-06-07 12:10:27 -04:00
steveseguin
fac763e14c revised obs.html -> obs/ 2025-06-07 01:14:39 -04:00
steveseguin
9c996318c0 Mbps to mbps 2025-06-06 01:14:28 -04:00
Steve Seguin
90008b15fa Merge pull request #1195 from MorseTheCode/develop-obs-dock
new obs.html + translations
2025-06-04 19:51:33 -04:00
MorseTheCode
2464ac98b9 Add files via upload 2025-05-30 16:05:01 -03:00
MorseTheCode
c798bb5c96 Update obs.html 2025-05-30 16:03:24 -03:00
steveseguin
9db8195421 delete 2025-05-25 11:39:12 -04:00
Steve Seguin
383343851e Merge pull request #1193 from steveseguin/steveseguin-patch-3
Delete .claude directory
2025-05-25 11:37:20 -04:00
Steve Seguin
2b7acdac8b Delete .claude directory 2025-05-25 11:37:07 -04:00
steveseguin
c0bfb22ef2 making a mess of the documentation 2025-05-25 11:35:21 -04:00
steveseguin
1168962436 tweaks to reconnection logic 2025-05-23 20:00:04 -04:00
steveseguin
19310ddeed &eq fix 2025-05-23 13:31:40 -04:00
Steve Seguin
b5fba7d6e7 Add files via upload 2025-05-19 15:04:59 -04:00
Steve Seguin
2335172c23 Merge pull request #1190 from steveseguin/steveseguin-patch-2
Update README.md
2025-05-12 21:49:57 -04:00
Steve Seguin
71b15a94dd Update README.md 2025-05-12 21:49:46 -04:00
steveseguin
c9380616de merge fix 2025-05-12 21:45:08 -04:00
Steve Seguin
4523a0bb83 Merge pull request #1189 from steveseguin/steveseguin-patch-1
Update turnserver.md
2025-05-12 11:16:38 -04:00
Steve Seguin
f3580248cd Update turnserver.md 2025-05-12 11:15:49 -04:00
Steve Seguin
897e20068c Add files via upload 2025-05-09 03:02:30 -04:00
Steve Seguin
239a3a6d05 Add files via upload 2025-05-09 03:01:56 -04:00
Steve Seguin
ca289d1bbb Add files via upload 2025-05-09 03:01:18 -04:00
Steve Seguin
ea7cbeb4f1 Merge pull request #1186 from RabbitJun/develop
Update cn.json to fix some translation
2025-04-04 10:50:27 -04:00
RabbitJun
e11326c99d Update cn.json to fix some translation 2025-04-04 22:21:27 +08:00
Steve Seguin
eccb7119d4 Merge pull request #1184 from PGSCOM/contribute-2
Fix: Language reorder by time zone
2025-04-01 17:51:18 -04:00
PGSCOM
4c570889bf Fix: Language reorder by time zone 2025-04-01 22:47:44 +02:00
Steve Seguin
5aee4d3bdb Merge pull request #1183 from steveseguin/steveseguin-patch-13
json linting fixes
2025-03-31 22:48:10 -04:00
Steve Seguin
98f9687c6a json linting fixes 2025-03-31 22:47:55 -04:00
Steve Seguin
51cdebe934 Merge pull request #1182 from PGSCOM/patch-2
Complete spanish translation
2025-03-31 22:41:13 -04:00
PGSCOM
7b72847cc1 Refine Spanish translations 2025-03-31 22:23:42 +02:00
PGSCOM
b61a915add Missing translations 2025-03-31 20:22:26 +02:00
PGSCOM
a3cf82d15d Fix: missing comma 2025-03-31 18:26:08 +02:00
PGSCOM
23b5434d9b Spanish translation 2025-03-31 18:03:47 +02:00
steveseguin
91fed124ca moving to the right folder 2025-03-24 18:34:06 -04:00
steveseguin
c6ceb430de ital updated; ty 2025-03-24 12:55:19 -04:00
Steve Seguin
3619cee3ed Merge pull request #1178 from steveseguin/steveseguin-patch-11
Update turnserver.md
2025-03-22 12:15:41 -04:00
Steve Seguin
2c67bdb773 Merge pull request #1179 from steveseguin/steveseguin-patch-12
Update turnserver.md
2025-03-22 12:15:30 -04:00
Steve Seguin
1faebe7809 Update turnserver.md 2025-03-22 12:09:29 -04:00
Steve Seguin
9956c77cf2 Update turnserver.md 2025-03-22 12:01:26 -04:00
Steve Seguin
5b4482ec0d Merge pull request #1177 from steveseguin/steveseguin-patch-10
Update turnserver_install.sh.sample
2025-03-22 11:55:20 -04:00
Steve Seguin
43945838f0 Update turnserver_install.sh.sample 2025-03-22 11:54:53 -04:00
Steve Seguin
89ac01621b Merge pull request #1175 from T0biii/patch-2
Remove Chrome/Edge v133 Known issue
2025-03-16 15:29:40 -04:00
Tobias
b1a70e7fb5 Merge remote-tracking branch 'upstream/develop' into patch-2 2025-03-10 18:05:15 +00:00
Steve Seguin
c3c11ff23a Merge pull request #1176 from T0biii/patch-3
add Update Info to Known Issue
2025-03-10 13:45:50 -04:00
Tobias
c315b43a48 add Update Info 2025-03-10 17:40:00 +00:00
Tobias
ec0c44a046 Remove Chrome/Edge v133 Known issue
as https://issues.chromium.org/issues/398149360 is on status Fixed i think you can remove it
2025-03-10 18:24:53 +01:00
steveseguin
d4851c9862 mixer / slots mode updates 2025-03-03 13:21:22 -05:00
Steve Seguin
222e91e64a corrections to json 2025-02-28 04:09:27 -05:00
Steve Seguin
98c7354caa Merge pull request #1171 from RabbitJun/develop
Update cn.json for some fix

Thank you kindly for your contribution.
2025-02-28 04:03:55 -05:00
RabbitJun
3f7f7ff220 Update cn.json for some fix 2025-02-28 15:45:28 +08:00
Steve Seguin
e8b7822d19 fix for firefox + mixer app 2025-02-27 09:37:32 -05:00
steveseguin
ec02e9b233 fix for obs v31 iframe crash 2025-02-12 03:18:22 -05:00
steveseguin
ef9eb8af1b improved translations + translation system 2025-02-05 20:15:20 -05:00
steveseguin
9c584a9f8d updated translations 2025-02-05 19:39:43 -05:00
steveseguin
b3054d062e fix for sstype3 2025-02-04 23:15:43 -05:00
Steve Seguin
81e323b37f Add files via upload 2025-02-04 23:09:44 -05:00
Steve Seguin
5acc14eaf5 Merge pull request #1162 from al3bsi/patch-2
ar.json
2025-02-04 22:26:56 -05:00
al3bsi
b52421a2e2 ar.json 2025-02-05 01:46:04 +03:00
steveseguin
d2cb661c9e sync with production+alpha; v27.0 2025-01-31 01:51:08 -05:00
steveseguin
9f978ff4ef . 2025-01-13 01:40:23 -05:00
steveseguin
1358ba6299 . 2025-01-13 01:36:03 -05:00
steveseguin
672bf89dec further improve turnserver install script 2025-01-13 01:34:33 -05:00
steveseguin
098740f67b readme turn server tweak 2025-01-13 01:13:55 -05:00
steveseguin
cd33796ca7 local llink 2025-01-12 19:45:37 -05:00
steveseguin
f1fd5d4a06 readme updates 2025-01-12 19:44:13 -05:00
steveseguin
bc96ee5f4a done 2025-01-12 19:23:44 -05:00
steveseguin
6f06a8a8e8 fine turning turnserver install script 2025-01-12 19:20:02 -05:00
steveseguin
c1c4a0def1 verifying new turn instucts 2025-01-12 18:41:28 -05:00
steveseguin
9ef61ee12f firewall rules added 2025-01-12 18:15:24 -05:00
steveseguin
80c170dd69 turn server install guide improved 2025-01-12 18:07:19 -05:00
steveseguin
b17ab24bdc fix in rawdoc 2025-01-10 18:24:45 -05:00
Steve Seguin
7d44e1c457 Merge pull request #1159 from steveseguin/steveseguin-patch-9
Update README.md
2025-01-10 18:20:39 -05:00
Steve Seguin
bfd8d17e91 Update README.md 2025-01-10 18:20:29 -05:00
Steve Seguin
caae81985d Merge pull request #1158 from steveseguin/steveseguin-patch-8
Update README.md
2025-01-10 18:15:52 -05:00
Steve Seguin
34bfd80c41 Update README.md 2025-01-10 18:15:41 -05:00
steveseguin
0b68ac1f9b 26.5 sync with alpha. 2025-01-10 18:12:23 -05:00
steveseguin
b1c1385142 26.5 sync with alpha 2025-01-10 18:12:23 -05:00
Steve Seguin
f72063c0ce Merge pull request #1157 from steveseguin/steveseguin-patch-8
Update README.md
2025-01-09 04:13:12 -05:00
Steve Seguin
e6c255c84f Update README.md 2025-01-09 04:13:00 -05:00
Steve Seguin
03a5329e54 Updating readme; merging. Just some organizing
Update README.md
2025-01-09 04:08:45 -05:00
Steve Seguin
28caf91333 Update README.md 2025-01-09 04:08:12 -05:00
Steve Seguin
e2ae727e02 Update README.md 2025-01-09 04:07:20 -05:00
Steve Seguin
d0ec18d77e Update README.md 2025-01-09 04:05:53 -05:00
Steve Seguin
3a30dd3bbb Update README.md 2025-01-09 04:05:28 -05:00
Steve Seguin
44ef4a81cf Update README.md 2025-01-09 04:04:49 -05:00
Steve Seguin
9968cd0b0e Update README.md 2025-01-09 04:04:08 -05:00
Steve Seguin
b700bb5e22 Update README.md 2025-01-09 04:02:53 -05:00
Steve Seguin
2cd7eda4ac Update README.md 2025-01-09 04:02:32 -05:00
Steve Seguin
221760374b Update README.md 2025-01-09 04:02:23 -05:00
Steve Seguin
497a99e45c Update README.md 2025-01-09 04:02:00 -05:00
Steve Seguin
1c9c86949b Update README.md 2025-01-09 03:59:44 -05:00
Steve Seguin
74be8a9008 Update README.md 2025-01-09 03:58:32 -05:00
steveseguin
e93c2eb090 meshcast/whip/mediamtx fix for screen sharing 2024-11-02 01:57:32 -04:00
steveseguin
46fb393b94 fix for Firefox v132 2024-11-01 20:13:21 -04:00
Steve Seguin
e512cc4b1e Merge pull request #1150 from yonghuang28/yonghuang28-patch-1
&feedbackbutton tweak
2024-11-01 15:53:17 -04:00
Yong
3a4ecd0dda &feedbackbutton tweak
A small tweak for &feedbackbutton
(1) The &feedbackbutton only accept value by &fb (e.g., &fb=50), but &feedbackbutton=50 won't work. With this patch, both way will work.
(2) The button prompt info always show "Hear yourself at 50% volume" without dynamic showing the real user defined &fb or &feedbackbutton value. With this patch, the showing can sync with the user assigned value.
2024-11-01 13:04:59 -04:00
steveseguin
7cb322af80 feedback button added 2024-10-26 05:30:34 -04:00
steveseguin
c055bfea4c order for audio devices fix 2024-10-26 05:12:05 -04:00
Steve Seguin
f8444c3120 Merge pull request #1145 from marclaporte/patch-2
Fix typo
2024-10-20 23:37:31 -07:00
Marc Laporte
5d43e71cef Fix typo 2024-10-21 02:32:56 -04:00
steveseguin
0b9ae1a655 minor bug fix with main menu 2024-10-20 19:35:27 -04:00
Steve Seguin
1381729cd4 Merge pull request #1144 from lukaw3d/patch-3
Fix generated input ids for boolean inputs
2024-10-20 15:40:15 -07:00
steveseguin
6221f5f292 sync widget fix 2024-10-20 18:39:04 -04:00
Steve Seguin
24aea29932 Merge pull request #1143 from lukaw3d/patch-2
Fix turning off torch remotely from director view
2024-10-19 18:43:13 -07:00
Luka Jeran
8d54e68cab Fix generated input ids for boolean inputs 2024-10-20 02:48:27 +02:00
Luka Jeran
52351d9a34 Fix turning off torch remotely from director view 2024-10-20 02:33:31 +02:00
steveseguin
4777522239 basic remote exposure control 2024-10-19 01:40:16 -04:00
steveseguin
d91cde97de Version 26.0 release 2024-10-17 04:34:18 -04:00
Steve Seguin
fdc710164c Merge pull request #1140 from steveseguin/steveseguin-patch-7
Update README.md
2024-09-27 23:05:25 -04:00
Steve Seguin
f206ab5bab Update README.md 2024-09-27 23:05:05 -04:00
Steve Seguin
37accc36cf Merge pull request #1139 from steveseguin/steveseguin-patch-6
Create static.yml
2024-09-27 22:58:00 -04:00
Steve Seguin
c736c1f64e Create static.yml 2024-09-27 22:57:14 -04:00
steveseguin
8f51d03f9d brown fix 2024-08-22 16:03:10 -04:00
steveseguin
8cc898b965 v26 beta 2024-08-22 15:39:54 -04:00
steveseguin
4fa58115a3 mediamtx native support; drawing added; isolated channels 2024-07-24 13:20:31 -04:00
Steve Seguin
f3425d43ea Merge pull request #1134 from RabbitJun/develop
Update cn.json_Fix the grammatical errors in some sentences
2024-07-08 13:57:35 -04:00
RabbitJun
f81e9364ba Update cn.json_Fix the grammatical errors in some sentences 2024-07-08 22:39:35 +08:00
Steve Seguin
730b776273 Merge pull request #1133 from steveseguin/steveseguin-patch-4
Update turnserver.md
2024-07-06 02:17:38 -04:00
Steve Seguin
b888e0a674 Update turnserver.md 2024-07-06 02:17:10 -04:00
steveseguin
86ab7ce458 Merge branch 'Organized-css-files' into develop 2024-06-23 02:39:16 -04:00
steveseguin
2f1d343cbe fix conflicts 2024-06-23 02:36:48 -04:00
steveseguin
c1790af404 change group api fix; improved enumerate messaging fix 2024-06-23 02:31:49 -04:00
Steve Seguin
678ca4e693 Merge pull request #1130 from Andrew-Gallimore/Organized-css-files
Organize CSS files
2024-06-23 02:24:17 -04:00
Steve Seguin
dca3d95532 Merge branch 'develop' into Organized-css-files 2024-06-23 02:23:34 -04:00
steveseguin
2e1de4126f tweaks to the new css structure and removed room.html 2024-06-12 00:50:28 -04:00
steveseguin
c87dedd002 handshake verbosity tweaks; fake guest labels 2024-06-11 23:46:33 -04:00
Andrew Gallimore
d1ccb8a6b0 Added <link>'s for new css files in HTML/JS files 2024-06-11 20:54:59 -04:00
Andrew Gallimore
43fd698ff7 Sorted out main.css to animations.css, and icons.css, variables.css 2024-06-07 12:23:04 -04:00
Steve Seguin
e317fe82ab Merge pull request #1129 from RabbitJun/develop
Update cn.json
2024-06-04 22:53:05 -04:00
RabbitJun
369b544722 Add files via upload
Correct a few inaccurate translations
2024-06-05 10:31:02 +08:00
Steve Seguin
e3bf8c7393 Merge pull request #1128 from steveseguin/steveseguin-patch-3
Update ru.json; СТАРТ
2024-06-04 18:31:38 -04:00
Steve Seguin
b1ee02d53e Update ru.json 2024-06-04 18:31:17 -04:00
Steve Seguin
655584bf39 Merge pull request #1127 from RabbitJun/develop
Update cn.json
2024-06-04 02:15:21 -04:00
RabbitJun
113516a2be Add files via upload
Fine-tune to keep consistent with the English version
2024-06-04 14:13:44 +08:00
Steve Seguin
b2bbc933a8 Merge pull request #1126 from RabbitJun/develop
Update cn.json
2024-06-04 01:59:30 -04:00
RabbitJun
0230e0aad8 Add files via upload
Third adjustment of translation issues
2024-06-04 13:57:54 +08:00
Steve Seguin
d52b762f90 Merge pull request #1125 from RabbitJun/develop
Update cn.json
2024-06-04 01:21:20 -04:00
RabbitJun
df389c0cd9 Add files via upload
Fixing grammatical errors and minor issues
2024-06-04 13:19:47 +08:00
Steve Seguin
c6c1c74b35 Merge pull request #1124 from RabbitJun/develop
Update cn.json
2024-06-03 13:12:37 -04:00
RabbitJun
a8683bb9b7 Add files via upload
Fixed numerous grammatical issues for improved clarity.
2024-06-03 13:47:49 +08:00
Steve Seguin
a6da4bbf45 Merge pull request #1123 from steveseguin/patch-1
Update cn.json
2024-06-02 13:26:19 -04:00
Steve Seguin
9da9870b55 Update cn.json
minor json validation fix
2024-06-02 13:24:22 -04:00
Steve Seguin
61c18bfba8 Merge pull request #1122 from RabbitJun/develop
Update cn.json
2024-06-02 13:21:17 -04:00
RabbitJun
46f5eeec62 Update cn.json
Supplement and translate a large amount of English into Chinese.
2024-06-02 23:23:09 +08:00
steveseguin
80c332e520 WHEP PATCH tweak; media to file recording fix 2024-05-25 14:13:36 -04:00
steveseguin
b0e453cea5 minor fixes for &morescenes and &showdirector in the mixer app 2024-05-20 02:41:29 -04:00
steveseguin
b8dfab2dbc ask a user for password via index easier now 2024-04-30 15:06:03 -04:00
steveseguin
8913ea29c5 bump version for cache 2024-04-28 05:08:09 -04:00
steveseguin
1d859cd3ec iframe transparency support added; ios mute fix added to all mobile 2024-04-28 01:38:13 -04:00
steveseguin
bc7cc07438 prettier file added and applied; devs rejoice?? 2024-04-28 01:37:04 -04:00
steveseguin
77c1894483 fix for presets /w &labels 2024-04-20 15:30:22 -04:00
steveseguin
519bb856d9 rotate button and &ln=auto fix for 404 2024-04-17 16:43:30 -04:00
steveseguin
04c1f3eac1 fix for &preset, &hidehome and &webcam 2024-04-14 15:41:05 -04:00
steveseguin
2937723961 fix for &viewslot; exclusive audio 2024-04-08 09:24:30 -04:00
steveseguin
8a18d96532 moving some langauges around 2024-04-01 04:38:11 -04:00
steveseguin
202e5fe8c2 adding some new translation fields 2024-04-01 04:33:10 -04:00
Steve Seguin
7d9632aa9d Merge pull request #1118 from steveseguin/steveseguin-patch-2
Update README.md
2024-03-29 04:18:11 -04:00
Steve Seguin
80aa9a3d5d Update README.md 2024-03-29 04:18:01 -04:00
Steve Seguin
8a57a3f0f3 Merge pull request #1117 from steveseguin/steveseguin-patch-1
Update README.md
2024-03-29 04:17:36 -04:00
Steve Seguin
627a88cf5e Update README.md 2024-03-29 04:17:16 -04:00
steveseguin
d82ab92df1 version 25.0 2024-03-29 03:06:12 -04:00
steveseguin
4a5f7690cd added &ln=auto as an option, etc 2024-03-28 13:55:56 -04:00
steveseguin
ae19ce9564 sync recent changes from alpha 2024-03-26 15:30:59 -04:00
steveseguin
b2224a31b5 fixing error in ital translation file 2024-03-26 15:26:36 -04:00
Steve Seguin
73464d6330 Merge pull request #1116 from hamza1311/patch-1
Fix 'server' being eaten by GitHub in codeblock
2024-03-22 14:02:43 -04:00
Elina
b105d26242 add line break after ```
"```server" is treated as a code block with the language of `server`, thus hiding it from the rendered code block
2024-03-22 19:51:26 +05:00
steveseguin
90ff4619e5 presets.json can support ?i now also 2024-03-17 14:59:23 -04:00
steveseguin
af67ac1428 &preset option added 2024-03-17 14:38:52 -04:00
Steve Seguin
a11012c8bd Merge pull request #1114 from theprincy/patch-14
Update it.json
2024-03-16 11:57:57 -04:00
Notelseit.com
99ea0985dc Update it.json 2024-03-16 09:21:29 +01:00
steveseguin
e4e2707716 undoing sync test 2024-03-15 13:10:18 -04:00
steveseguin
48fbc9e676 testing out synced translations 2024-03-15 13:06:57 -04:00
steveseguin
f1509c9867 checking in recent updates, like &rows 2024-03-15 12:08:15 -04:00
Steve Seguin
edeb83a180 Merge pull request #1113 from theprincy/patch-13
Update it.json
2024-03-15 11:48:58 -04:00
Notelseit.com
7dd5f6fe58 Update it.json 2024-03-15 09:01:55 +01:00
steveseguin
8e59863e91 fix for whep url doubling 2024-02-29 08:56:08 -05:00
steveseguin
32a3cc969f WHIP out clean up added on reload or hangup 2024-02-27 11:14:31 -05:00
steveseguin
48d4494a2a fix for codec=red /w firefox 2024-02-12 00:33:52 -05:00
steveseguin
baa5d78b7b minor fix for the ptz example app 2024-02-08 06:04:35 -05:00
steveseguin
35ad8832fe v24.7; ptz example, tweaked &chunked mode, iOS mute fix 2024-02-08 05:53:06 -05:00
steveseguin
8bcc9ac23c examples update 2024-01-29 03:42:40 -05:00
steveseguin
7072d32f58 ok/cancel translation fix + chunked changes; untested 2024-01-29 03:41:05 -05:00
steveseguin
4845391035 dev progress update; minor changes 2024-01-17 03:52:10 -05:00
steveseguin
e54b2d1fd9 minor fixes for v24 2024-01-04 15:19:31 -05:00
steveseguin
4c8b806e4b minor fixes for v24 2024-01-04 15:19:01 -05:00
steveseguin
01d7958c93 minor fix for &broadcast not showing preview 2023-12-21 13:02:32 -05:00
steveseguin
af60dab4db front page message change 2023-12-19 10:32:19 -05:00
steveseguin
3230e961e3 version 24.4 happy holidays 2023-12-19 10:14:38 -05:00
Steve Seguin
d0e0d7e04d comms/mixer app + electronCapture elevated priv support
electron capture might not have worked with the mixer or comms app when in elevateed privs. Fixed the issue I think
2023-11-13 20:23:44 -05:00
steveseguin
2a24446848 a month of minor feature adds and fixes 2023-11-08 01:02:05 -05:00
steveseguin
0a53b23195 svc support in whip.html; pixel preview freezing fix; whep improvements; motion recorder 2023-10-20 23:54:13 -04:00
steveseguin
782ccda08f v24.1 beta; audio sample fix + &retrasmit fun 2023-10-17 11:45:44 -04:00
steveseguin
a930d77d08 limittotalbitrate mobile and desktop dual option 2023-09-29 04:56:20 -04:00
steveseguin
1a6378edbb pass &trb=desktop,mobile bitrates 2023-09-27 03:17:03 -04:00
steveseguin
7694dec33a fix for screen share + zoom 2023-09-25 02:59:10 -04:00
steveseguin
883bd18410 v24.b ; cloudflare as meshcast support; motiondetection 2023-09-08 00:25:35 -04:00
steveseguin
7154b90c03 v24b, with cloudflare/whip->whep auto viewer support 2023-08-28 04:36:45 -04:00
steveseguin
676d831e20 removing extra spaces/tabs 2023-08-08 16:54:29 -04:00
steveseguin
27a3f661d8 tabbed formatting for css 2023-08-08 16:47:37 -04:00
steveseguin
e3aa75e81c redo body flex change 2023-08-08 16:42:04 -04:00
steveseguin
32ce9cc53e Revert "Merge pull request #1086 from HonKLam/update-header-css"
This reverts commit ada283aa69, reversing
changes made to 4058553964.
2023-08-08 16:32:39 -04:00
steveseguin
0f2e662056 Revert "fixing conflict"
This reverts commit 2c8ae58d68.
2023-08-08 16:18:11 -04:00
steveseguin
2c8ae58d68 fixing conflict 2023-08-08 15:35:52 -04:00
Steve Seguin
ada283aa69 Merge pull request #1086 from HonKLam/update-header-css
Rework Header of Mainpage
2023-08-08 15:28:31 -04:00
Lamo
4c4d58c8e6 Revert Removal of Logoname + Add minimal styling 2023-08-08 21:24:38 +02:00
Lamo
9972fa89e5 Rework Header 2023-08-08 20:13:16 +02:00
steveseguin
4058553964 remove origins 2023-08-06 22:38:03 -04:00
steveseguin
96d8a1bfa4 try #2 2023-08-06 22:37:23 -04:00
steveseguin
518fd4f8f9 togglefullscreen sstype3 fix 2023-08-06 21:07:28 -04:00
steveseguin
4749fe3bbf fix issue where undefined crashes pip2 2023-08-05 09:13:25 -04:00
steveseguin
4dde5190e1 remove origin trial meta 2023-08-05 08:46:57 -04:00
steveseguin
9b8ec9adb3 &pipall added 2023-08-03 16:26:59 -04:00
steveseguin
db09fe5880 v23.8 2023-07-25 06:20:57 -04:00
Steve Seguin
9bd5d39eb3 Merge pull request #1081 from yonghuang28/&batterymeter---remove-old-code-in-comment-blocks
&batterymeter - remove old code in comment blocks
2023-07-15 21:05:46 -04:00
Yong
08ab1cde53 &batterymeter - remove old code in comment blocks
Nothing new, I just removed the old code that had been moved into comment blocks of lib.js, due to the add on of "&batterymeter". Make the code looks neat and ready for next update.
2023-07-15 20:53:30 -04:00
Steve Seguin
43c0854ae5 Merge pull request #1078 from yonghuang28/add-a-URL-parameter-&batterymeter
Add a url parameter &batterymeter
2023-07-11 06:37:42 -04:00
steveseguin
640fb7b0db whip improvements 2023-07-11 06:37:14 -04:00
Yong
dbb50f20cb for director, add icon on screenShare
for director, add icon on screenShare
--
function createControlBoxScreenshare(UUID, soloLink, streamID) {
37261	if (session.signalMeter){
2023-07-08 20:47:40 -04:00
Yong
5d62f3e287 for director, add icon on screenShare
for director, add icon on screenShare
2023-07-08 20:31:20 -04:00
Yong
c3927d7d2e for director, Separate the logic of &signalmeter and &batterymeter
for director,  Separate the logic of &signalmeter and &batterymeter
--
if (session.signalMeter){
if (session.batteryMeter){
2023-07-08 20:27:50 -04:00
Yong
a6d7b237c2 update async function createRoomCallback
update async function createRoomCallback

16374	if (session.signalMeter===null){
			session.signalMeter = true;
		}
2023-07-08 20:20:18 -04:00
Yong
80404d5a92 showing battery icon on each vdo stream, including ScreenShare
showing battery icon on each vdo stream, including ScreenShare

function updateMixerRun(e=false){ 
	mediaPool.forEach(vid=>{
5699	if (session.signalMeter){
2023-07-08 19:58:31 -04:00
Yong
16fc486476 function switchModes(state=null)
function switchModes(state=null)
2023-07-08 19:52:58 -04:00
Yong
75469d9e3f add a new urlParams &batterymeter
add a new urlParams &batterymeter
2023-07-08 19:48:30 -04:00
Yong
50a86c31f7 Add blinking effects for .battery.warn and .battery.alert
Add blinking effects for .battery.warn and .battery.alert
2023-07-08 19:46:02 -04:00
Yong
527929cf95 Add blinking effects for .battery.warn and .battery.alert
Add blinking effects for .battery.warn and .battery.alert
2023-07-08 19:42:15 -04:00
steveseguin
5a42948014 whip page only partially done in this push 2023-07-07 01:26:12 -04:00
steveseguin
7ee9653dfd fixes, features, and darkmode=true fix 2023-06-29 02:20:57 -04:00
steveseguin
70262002db Revert "Merge branch 'master' into develop"
This reverts commit 500d2ecf34, reversing
changes made to ed1716c23c.
2023-06-26 04:40:19 -04:00
Steve Seguin
500d2ecf34 Merge branch 'master' into develop 2023-06-26 04:24:14 -04:00
steveseguin
ed1716c23c v23.5 updates 2023-06-14 17:19:32 -04:00
Steve Seguin
49c7590123 Merge pull request #1076 from vuvuvu/develop
fixed typo

Thank you
2023-06-07 10:10:58 -04:00
vUvu
e7909b55d2 fixed typo 2023-06-07 15:48:56 +10:00
Steve Seguin
04ccbc3180 Merge pull request #1075 from steveseguin/steveseguin-patch-1
Update CONTRIBUTING.md
2023-05-31 09:29:00 -04:00
Steve Seguin
4b5ed72722 Update CONTRIBUTING.md 2023-05-31 09:26:42 -04:00
steveseguin
aadc5fbf3e reconnection fix 2023-05-26 11:20:27 -04:00
steveseguin
0afd701fb9 screenshare reload and scene issue fixes 2023-05-13 03:52:45 -04:00
steveseguin
a7bd36e46b fix for screen share; &nomeshcast added 2023-05-11 07:50:18 -04:00
steveseguin
e8a672de77 fix for the mute status icon not showing in director controls 2023-05-08 18:34:05 -04:00
steveseguin
9f1130daff upload button might show unexpectedly fix 2023-05-08 06:32:09 -04:00
steveseguin
74505e8cd0 v23.4 2023-05-08 05:32:19 -04:00
Steve Seguin
55414d1133 Merge pull request #1069 from thifranc/patch-1
typo in doc
2023-05-03 17:53:04 -04:00
Thibault Francois
a81c113b91 typo in doc 2023-05-03 10:56:56 +02:00
steveseguin
b66a7ac3f3 mixer updates + &relay fix 2023-05-01 20:19:50 -04:00
steveseguin
d1ff359c60 fixing warn error 2023-04-26 23:08:01 -04:00
steveseguin
b2364edcf0 a few fixes here and there 2023-04-26 20:09:18 -04:00
Steve Seguin
beff7c25df Merge pull request #1068 from steveseguin/modified_pr
Modified pr
2023-04-26 19:27:38 -04:00
steveseguin
c4c2d5aaae Resolved merge conflict 2023-04-26 19:25:28 -04:00
steveseguin
3b5ff207d0 mixer update; minor additions elsewhere 2023-04-26 19:17:31 -04:00
steveseguin
c6dbbb856f minor tweaks 2023-04-26 18:20:53 -04:00
steveseguin
50cc8e2077 more tweaks 2023-04-26 16:42:09 -04:00
steveseguin
44c56e42a5 another tweak 2023-04-26 16:21:02 -04:00
steveseguin
7ac7eb72c5 working my way thru tweaking colors 2023-04-26 16:16:06 -04:00
steveseguin
0953ffc99c lightening card bg to fix issue 2023-04-26 13:56:49 -04:00
Steve Seguin
fdf4c18bb5 Merge pull request #1067 from steveseguin/develop
sync main
2023-04-26 13:53:34 -04:00
steveseguin
c451fa1b20 fixing conflict 2023-04-26 13:53:03 -04:00
steveseguin
96b0dd420b removing aspects of the PR that don't vibe 2023-04-26 13:47:36 -04:00
steveseguin
175a7c8672 Revert "Generate a links category for ss too"
This reverts commit aa51dc4333.
2023-04-26 13:44:56 -04:00
steveseguin
60806da160 Revert "NINJA STARHS!"
This reverts commit 90dc678e90.
2023-04-26 13:43:01 -04:00
steveseguin
1658344e9a Revert "background image"
This reverts commit 510a18ba3f.
2023-04-26 13:42:37 -04:00
steveseguin
e3c00ad539 Revert "Unsure about this one. What does it break?"
This reverts commit d39b8fa001.
2023-04-26 13:41:02 -04:00
steveseguin
67ca08c677 Revert "revert #mainmenu height"
This reverts commit 60283c8ba4.
2023-04-26 13:40:48 -04:00
steveseguin
186bdd1862 Revert "Replaced background-images due to lackin darktheme"
This reverts commit ed9a0b3d93.
2023-04-26 13:40:14 -04:00
steveseguin
4b73012930 Revert "NINJA STARHS!"
This reverts commit 90dc678e90.
2023-04-26 13:30:21 -04:00
steveseguin
329121c395 fix for &cover and &border 2023-04-21 11:40:57 -04:00
steveseguin
361ab7a3f6 updated check test page with bandwidth component 2023-04-21 00:49:04 -04:00
steveseguin
257d2c8468 &structure, &blur, button press 2023-04-19 15:34:47 -04:00
lindenkron
ed9a0b3d93 Replaced background-images due to lackin darktheme 2023-04-19 03:15:21 +02:00
lindenkron
60283c8ba4 revert #mainmenu height 2023-04-19 02:25:32 +02:00
lindenkron
510a18ba3f background image 2023-04-19 02:14:10 +02:00
lindenkron
4db46891bd Lightmode X close 2023-04-19 01:39:45 +02:00
lindenkron
d39b8fa001 Unsure about this one. What does it break? 2023-04-19 01:30:49 +02:00
lindenkron
90dc678e90 NINJA STARHS! 2023-04-19 01:22:07 +02:00
lindenkron
08a12c5045 ppu2 2023-04-19 00:57:49 +02:00
lindenkron
8eb63b1f37 ppu 2023-04-19 00:48:38 +02:00
lindenkron
30cafd5dd3 unset input height for room/psw 2023-04-19 00:46:32 +02:00
lindenkron
aa51dc4333 Generate a links category for ss too 2023-04-19 00:42:10 +02:00
lindenkron
a47baccbf8 Another miss 2023-04-18 23:43:37 +02:00
lindenkron
74a693af50 missed solo link title 2023-04-18 23:41:21 +02:00
lindenkron
a2141b48a0 Initial commits 2023-04-18 23:38:31 +02:00
steveseguin
cc48729d8a improving the bitrate for scene/directo switching 2023-04-17 20:59:50 -04:00
Steve Seguin
6521dde49d Merge pull request #1064 from lindenkron/director-buttons-disabled
Disabled buttons
2023-04-17 19:32:12 -04:00
lindenkron
0dc41e40fd Fixed a a:visited bug. 2023-04-18 01:31:24 +02:00
lindenkron
bbb2616e60 Volume slider 2023-04-18 01:27:49 +02:00
lindenkron
86b91582ea Added mute-guest to disabled 2023-04-18 01:26:45 +02:00
lindenkron
100330a0de Missed groups & mix 2023-04-18 01:26:08 +02:00
lindenkron
e0620aecbd Disabled buttons 2023-04-18 01:23:39 +02:00
Steve Seguin
c18e0d4e53 Merge pull request #1063 from lindenkron/Few-style-changes
Few-style-changes
2023-04-17 19:09:14 -04:00
lindenkron
ffd2debc60 Added disabled style. 2023-04-18 00:55:12 +02:00
lindenkron
47606397a9 A lot of general stylings. 2023-04-18 00:24:34 +02:00
lindenkron
5d1db70180 Rename the appended style classes for dir/codir 2023-04-17 23:32:27 +02:00
lindenkron
bf101cc9ac Reduced the size to regular border size. 2023-04-17 20:46:17 +02:00
lindenkron
0b137c69f3 Mistake 2023-04-17 20:44:29 +02:00
lindenkron
a0be92c1fe Unneeded 2023-04-17 20:44:15 +02:00
steveseguin
9c60f5f970 somewhat fixed chat now 2023-04-17 13:56:01 -04:00
steveseguin
1a9d9e2d3c reverting chat until fixed 2023-04-17 13:23:21 -04:00
steveseguin
e544bcbbfa toggle between video/audio 2023-04-17 12:57:05 -04:00
steveseguin
54ab0f19dc solo talk fix; eek 2023-04-17 11:40:23 -04:00
Steve Seguin
d8282852f3 Merge pull request #1062 from lindenkron/pop-out-again
Pop-out-again
2023-04-16 16:01:47 -04:00
lindenkron
bdcecbe754 Cleaned up comments 2023-04-16 20:56:53 +02:00
lindenkron
05b9c3faaf inMessage added 2023-04-16 20:54:35 +02:00
Steve Seguin
1ca13c8d85 Merge pull request #1061 from lindenkron/pop-out-again
Somehow this works.
2023-04-16 14:45:08 -04:00
lindenkron
e8702afea9 Somehow this works. 2023-04-16 20:38:40 +02:00
steveseguin
0a88b24b97 publish direct to twitch with vdo.ninja 2023-04-16 14:34:38 -04:00
steveseguin
c3cbd15b3b more stats for video/audio being added 2023-04-16 14:01:16 -04:00
Steve Seguin
ad053cd2e2 Merge pull request #1060 from lindenkron/pop-out-restyling-
Popout
2023-04-16 12:59:36 -04:00
steveseguin
351c00baec more log/event info 2023-04-16 12:58:44 -04:00
lindenkron
fbe6feb4d4 button alignment. 2023-04-16 17:11:08 +02:00
lindenkron
ebc8370a59 Missed two things. 2023-04-16 17:09:50 +02:00
lindenkron
986fba8b76 Popout 2023-04-16 17:06:46 +02:00
steveseguin
dd807f15ac first click fix 2023-04-16 09:28:27 -04:00
Steve Seguin
ca4411faa8 Merge pull request #1059 from lindenkron/calendar-position
Position re-added
2023-04-16 09:26:34 -04:00
steveseguin
284e854170 try 2 4 title fix 2023-04-16 09:25:32 -04:00
lindenkron
6de96c4d9a Position re-added 2023-04-16 15:24:23 +02:00
steveseguin
167eeb7e76 title fix; cal fix 2023-04-16 09:14:39 -04:00
steveseguin
c184e6cb7f tweak 2023-04-15 23:45:06 -04:00
steveseguin
e167c8dbd2 remote rejection iframe api events 2023-04-15 23:36:34 -04:00
Steve Seguin
c421498892 Merge pull request #1057 from lindenkron/Minor-styles
Minor fixes.
2023-04-15 23:35:43 -04:00
Steve Seguin
f73bfe5375 Merge pull request #1058 from lindenkron/translate
Translate
2023-04-15 23:35:19 -04:00
lindenkron
e466716357 Spanish trans 2023-04-16 02:55:27 +02:00
lindenkron
ef75e97571 German trans 2023-04-16 02:36:53 +02:00
lindenkron
8084afc60f French trans 2023-04-16 02:16:39 +02:00
lindenkron
3c5e05d477 add translate span for copy view link solo 2023-04-16 01:56:32 +02:00
lindenkron
a0e6d95453 Russian trans 2023-04-16 01:56:13 +02:00
lindenkron
eee580d58e controlsGrid button/button span ellipsis 2023-04-16 01:52:01 +02:00
lindenkron
622e87d3d7 Deal with some other language overflow on titles 2023-04-16 01:48:33 +02:00
lindenkron
c78e15c62b Minor fixes. 2023-04-16 01:18:32 +02:00
Steve Seguin
2c1de1080c Merge pull request #1056 from lindenkron/fixed-settings
Fixed settings.
2023-04-15 15:58:47 -04:00
lindenkron
8fca5a0c33 tittilu 2023-04-15 21:12:48 +02:00
lindenkron
4b31ae71dd Not pretty but works :P 2023-04-15 20:55:59 +02:00
lindenkron
e6105aca55 Fixed settings. 2023-04-15 20:32:56 +02:00
Steve Seguin
b8b773dcb1 Merge pull request #1055 from steveseguin/video-audio-setting-move
for lind to play with
2023-04-15 14:29:40 -04:00
steveseguin
fe4c59c230 query() 2023-04-15 14:20:20 -04:00
Steve Seguin
119ed82eda Merge pull request #1053 from lindenkron/missed-labels
I tested it, yet /alpha still has issues.
2023-04-15 14:17:32 -04:00
steveseguin
4fe031fc6e for lind to play with 2023-04-15 14:14:35 -04:00
lindenkron
fdcd2ae11a I tested it, yet /alpha still has issues. 2023-04-15 20:12:39 +02:00
Steve Seguin
4c2f2e236c Merge pull request #1052 from lindenkron/develop
Develop
2023-04-15 13:44:08 -04:00
lindenkron
021baa76e1 Fix over-correction of Disable Video 2023-04-15 19:43:17 +02:00
steveseguin
1279857f43 part 2 of translate updated 2023-04-15 13:12:24 -04:00
steveseguin
ddba2484f7 updated translations test 2023-04-15 13:10:01 -04:00
steveseguin
8ec4acb9ca force guests onto their own line 2023-04-15 13:00:45 -04:00
steveseguin
de5682a416 clean up 2023-04-15 12:53:03 -04:00
Steve Seguin
f7e363d215 Merge pull request #1051 from lindenkron/Translate-update
Translate update
2023-04-15 12:52:55 -04:00
Steve Seguin
dc759f7d87 Merge pull request #1050 from lindenkron/DirectorUI-v2
Director UI v2
2023-04-15 12:51:19 -04:00
lindenkron
c4aca7dd43 Converted to '3' row. 2023-04-15 17:35:04 +02:00
lindenkron
f3820ca6ac Translates added 2023-04-15 17:20:40 +02:00
lindenkron
76a1a3ef35 settingsWrapper for audio/video settings 2023-04-15 02:56:27 +02:00
lindenkron
bfa4bc8358 Director colorscheme on bnts (was still previous) 2023-04-15 02:32:24 +02:00
lindenkron
40ba5bc6c5 Two additional color fixes. 2023-04-15 02:31:55 +02:00
lindenkron
4941f7df64 Now includes working &lightmode 2023-04-15 02:22:50 +02:00
lindenkron
bfa519d1ac Initial changes (Dark mode only) 2023-04-15 02:13:48 +02:00
Steve Seguin
824ed2a42d Merge pull request #1048 from lindenkron/Options-menu
Options menu styling update.
2023-04-14 17:03:40 -04:00
lindenkron
4bf561a1e6 Options menu styling update. 2023-04-14 23:02:44 +02:00
steveseguin
00bcdf8743 sync 2023-04-14 15:47:40 -04:00
Steve Seguin
c80a649784 Merge pull request #1047 from lindenkron/Misplaced-graphs
Moved and adjusted Scene graph stylings.
2023-04-14 15:47:22 -04:00
lindenkron
8a9b62eaec Moved and adjusted Scene graph stylings. 2023-04-14 21:43:36 +02:00
steveseguin
2a3237a3ba fix chat layering 2023-04-14 14:51:51 -04:00
Steve Seguin
67e0eda31a Merge pull request #1046 from lindenkron/Missing-border-radius
Missing border-radius #press2talk hover
2023-04-14 14:07:56 -04:00
Steve Seguin
8d0a37ea54 Merge pull request #1045 from lindenkron/Remove-Zero-Height
Chat box autohide fix
2023-04-14 14:07:37 -04:00
steveseguin
f9e383290b slightly better support for two tracks with same id 2023-04-14 14:06:11 -04:00
lindenkron
3b48b541ad Missing border-radius #press2talk hover 2023-04-14 17:03:24 +02:00
lindenkron
b58253f4d4 Chat box autohide fix 2023-04-14 16:45:57 +02:00
steveseguin
c49d24d77a debug clean up 2023-04-13 02:23:30 -04:00
steveseguin
d4adf75895 tabs 2023-04-13 02:21:49 -04:00
steveseguin
7e3c2983f3 drag fix 2023-04-13 02:19:13 -04:00
steveseguin
04f454aabf cpu stats added 2023-04-12 23:10:40 -04:00
Steve Seguin
484fde4b4e Merge pull request #1043 from lindenkron/Mobile-fix
Settings menu & Apple mobile fixes
2023-04-12 18:57:38 -04:00
Steve Seguin
28d2e74064 Merge branch 'develop' into Mobile-fix 2023-04-12 18:57:29 -04:00
lindenkron
a232a6550e Final adjustment 2023-04-13 00:54:37 +02:00
steveseguin
2860b7c151 &fl fix 2023-04-12 18:53:46 -04:00
lindenkron
09d3face07 cursor Grab on dragging. 2023-04-13 00:52:01 +02:00
lindenkron
7d550e745a New min size 2023-04-13 00:51:20 +02:00
lindenkron
3ee53e4439 Minor fixes 2023-04-13 00:17:38 +02:00
lindenkron
3fa6a7b653 Media only screen - mobile button fix 2023-04-13 00:06:12 +02:00
lindenkron
0a24ee1dfc Notification button fix 2023-04-12 23:53:46 +02:00
lindenkron
e6dc40fc38 Cleared up comments 2023-04-12 23:36:29 +02:00
lindenkron
92ecba20f6 RE-remove auto bottom. 2023-04-12 23:29:13 +02:00
lindenkron
bf65917a64 Replace drag function HECK YE. 2023-04-12 23:26:01 +02:00
lindenkron
7451a7cddd Revert .bottom = "auto" removal. 2023-04-12 19:34:45 +02:00
steveseguin
760211ad6f codirector event change 2023-04-11 17:14:40 -04:00
steveseguin
3d31258704 mc fix + mixer bg fix 2023-04-11 16:02:51 -04:00
lindenkron
1537fac957 Adjustments, fixed auto breaking stuff. 2023-04-11 16:11:33 +02:00
lindenkron
d9eb370a72 Initial Mobile Chat resizing fix 2023-04-11 15:30:00 +02:00
lindenkron
032f2c83b8 230px again 2023-04-11 13:20:40 +02:00
lindenkron
126f51939f Adjustments 2023-04-11 13:15:27 +02:00
lindenkron
7756264e7f Settings menu & Apple mobile fixes 2023-04-11 12:53:17 +02:00
steveseguin
dc526ca038 sync 2023-04-10 20:37:39 -04:00
Steve Seguin
cd3082ccf4 Merge pull request #1042 from lindenkron/Fix-Darkmode-Globally
Fix darkmode globally
2023-04-10 20:37:05 -04:00
lindenkron
589ac51b2e Dammit missed outputSource3 2023-04-11 02:35:48 +02:00
lindenkron
b24a30b0c7 More missing elements with borders, radius etc 2023-04-11 02:24:24 +02:00
steveseguin
67356ba438 error handling 2023-04-10 20:14:12 -04:00
Steve Seguin
854698084d Merge pull request #1041 from lindenkron/Fix-Darkmode-Globally
Lot of style changes.
2023-04-10 20:13:55 -04:00
lindenkron
5f4d57ef26 Lot of style changes. 2023-04-11 02:13:10 +02:00
steveseguin
46db720204 sample audio/video off/on color state 2023-04-10 19:59:43 -04:00
Steve Seguin
1c52d91c05 Merge pull request #1040 from lindenkron/enable-mic
Defaults to standard colors instead of green
2023-04-10 19:44:24 -04:00
lindenkron
8b3f785733 Defaults to standard colors instead of green 2023-04-11 01:42:36 +02:00
Steve Seguin
dff926ac14 Merge pull request #1039 from lindenkron/small-fixes
More small things
2023-04-10 19:30:53 -04:00
lindenkron
3a6a437e20 Missed this. 2023-04-11 01:30:21 +02:00
lindenkron
ae51b7a17d More small things 2023-04-11 01:27:34 +02:00
Steve Seguin
5156fc8d32 Merge pull request #1038 from lindenkron/label
Label & slider updates
2023-04-10 19:12:06 -04:00
steveseguin
f962d9a6f2 conflict fix 2023-04-10 19:11:56 -04:00
lindenkron
eac8c0ee3b Label & slider updates 2023-04-11 01:03:08 +02:00
steveseguin
fa919e168c missing class for audio 2023-04-10 18:47:04 -04:00
Steve Seguin
e774b05fc6 Merge pull request #1037 from steveseguin/Screenshare-Buttons-fixed
sync merge
2023-04-10 18:29:40 -04:00
steveseguin
e001d2c016 sync merge 2023-04-10 18:23:48 -04:00
Steve Seguin
fe99027641 Merge pull request #1036 from steveseguin/Screenshare-Buttons-fixed
Screenshare buttons fixed
2023-04-10 18:18:32 -04:00
steveseguin
387889a90e Merge branch 'develop' of https://github.com/steveseguin/vdo.ninja into Screenshare-Buttons-fixed 2023-04-10 16:57:53 -04:00
steveseguin
5ca1c56e29 drag fix 2023-04-10 16:52:41 -04:00
Steve Seguin
578135e3a1 Merge pull request #1031 from lindenkron/subControlButtons
#controlButtons bar update
2023-04-09 17:29:00 -04:00
lindenkron
a2916ed1ff Merge pull request #3 from steveseguin/lindenkron-Screenshare-Buttons-fixed
Lindenkron screenshare buttons fixed
2023-04-09 21:17:16 +02:00
steveseguin
4f09a34bad conflict fix 2023-04-09 15:08:33 -04:00
Steve Seguin
7623946197 Merge pull request #1034 from lindenkron/Audio-Settings-fix
Attempting to fix the audio settings
2023-04-09 15:03:44 -04:00
steveseguin
35e97e96ca restart connection on sender side added via stats 2023-04-09 14:49:57 -04:00
lindenkron
d7ab9177aa Attempting to fix the audio settings 2023-04-09 20:37:22 +02:00
lindenkron
983667ecdc Final screenshare, no pulsate 2023-04-09 19:28:14 +02:00
lindenkron
657142a6a8 Fixes to button off state. 2023-04-09 19:07:18 +02:00
lindenkron
d489c5ffbc Fixed turning off screenshare color. 2023-04-09 17:05:06 +02:00
steveseguin
aebb5602e7 fix sstype=3 + mic mute breaking 2023-04-09 10:44:53 -04:00
lindenkron
a645d7ec8d Initial test 2023-04-09 16:03:37 +02:00
steveseguin
def151a5af screen share fix 2023-04-09 09:27:49 -04:00
steveseguin
b500b68ce3 fix for screenshare state not ending right 2023-04-09 08:40:25 -04:00
lindenkron
d249314f67 Replaces draggable function 2023-04-09 02:16:43 +02:00
lindenkron
ffc478bed5 Control Bar Mobile Sizing 2023-04-08 23:52:19 +02:00
lindenkron
6e5052f7e6 #controlButtons bar updated. 2023-04-08 19:24:14 +02:00
steveseguin
82ca2bc915 &miconlyoption &moo added 2023-04-07 20:16:05 -04:00
steveseguin
71206c75ca sync 2023-04-07 20:16:05 -04:00
Steve Seguin
a5f5788a3f Merge pull request #1030 from lindenkron/Dark-theme-including-mixer
Forgot highlight button
2023-04-07 19:57:50 -04:00
lindenkron
db05dfd0f2 Forgot highlight button 2023-04-08 01:51:57 +02:00
Steve Seguin
eeeaa41838 Merge pull request #1029 from lindenkron/Dark-theme-including-mixer
Fixed Director control panel & some missing icons
2023-04-07 19:47:48 -04:00
lindenkron
16f3421f11 Fixed Director control panel & some missing icons 2023-04-08 01:45:04 +02:00
Steve Seguin
0b84fff093 Merge pull request #1028 from lindenkron/Dark-theme-including-mixer
Dark-theme update fixes
2023-04-07 19:29:44 -04:00
lindenkron
b863d69450 Dark-theme update fixes 2023-04-08 01:25:36 +02:00
steveseguin
039960baa0 join with mic only button added 2023-04-07 17:31:00 -04:00
Steve Seguin
1b1eb7d0ee Merge pull request #1027 from lindenkron/Dark-theme-including-mixer
Dark theme including mixer
2023-04-07 17:30:52 -04:00
lindenkron
baf0c9812c Extra div for elements & styling changes. 2023-04-07 19:30:59 +02:00
lindenkron
b5d37f3f6b Light/Dark Modal fixes 2023-04-07 19:08:58 +02:00
lindenkron
5f9056d966 Apparently my version lost this commit? 2023-04-07 18:32:30 +02:00
lindenkron
78f3369066 Darkthemed Message box 2023-04-07 18:24:38 +02:00
Steve Seguin
067724a49a Merge pull request #1026 from yonghuang28/Add-a-dedicated-Refresh-button
Add a dedicated Refresh button
2023-04-07 11:49:09 -04:00
lindenkron
f7a5d8bce5 parent 8b53a0a8b4
author lindenkron <lindenkron@hotmail.com> 1680868460 +0200
committer lindenkron <lindenkron@hotmail.com> 1680872353 +0200

[Initial] Discord Dark Theme
2023-04-07 15:31:29 +02:00
Yong
aad6223d1e Add a dedicated Refresh button
The original onclick="history.go(0);" method only forces the browser to reload the page from the cache. But if we use iframes, that will not work as expected if we try/debug different parameters in URL(iframed). This dedicated button uses location.reload() method, which will reload the current URL in the parent window of the current frame. This will cause the entire parent window, including any frames or iframes, to reload, reflecting any changes made to the page. 

PS: my first github pull-request, yeah! Still need to learn more JavaScript tho.
2023-04-07 08:55:36 -04:00
steveseguin
8b53a0a8b4 nothing; just syncing 2023-04-07 03:44:22 -04:00
Steve Seguin
d7bedcdd33 Merge pull request #1024 from lindenkron/Fix-Audio-Settings-Styling
Fixed 'Audio Settings' menu styling.
2023-04-07 03:44:02 -04:00
Steve Seguin
bc1756c9b7 Merge pull request #1022 from lindenkron/Button-Content-Gap
Button-Content-Gap
2023-04-07 03:43:30 -04:00
lindenkron
9c398f6103 Fixed 'Audio Settings' menu styling. 2023-04-07 00:52:13 +02:00
lindenkron
ed5c3d3f6c Gap 2px to buttons (Check Mixer etc) 2023-04-06 17:04:19 +02:00
lindenkron
665e803d70 Removed unnecessary center (Everything is center) 2023-04-06 17:03:36 +02:00
Steve Seguin
34b95bb36c Merge pull request #1021 from lindenkron/Darktheme-fix-for-new-changes
Darktheme-fix-for-new-changes
2023-04-06 10:48:55 -04:00
lindenkron
5722d594ad Dark theme and minor margin/padding change 2023-04-06 16:46:42 +02:00
steveseguin
6488899edb tweak to alignment 2023-04-06 10:26:12 -04:00
lindenkron
a7595a8fe3 Merge pull request #1 from steveseguin/develop
sync
2023-04-06 16:09:18 +02:00
Steve Seguin
ff7c067f09 Merge pull request #1019 from lindenkron/Audio-Video-Settings
Audio-Video-Settings
2023-04-06 09:58:03 -04:00
Steve Seguin
b19f0de785 Merge pull request #1018 from lindenkron/Director-Styling
[Initial] Minor director finess changes.
2023-04-06 09:56:14 -04:00
Steve Seguin
53c74c5509 Merge pull request #1020 from lindenkron/Remove-Hard-style-padding
Removed requestAudioOutputDevice padding.
2023-04-06 09:55:35 -04:00
steveseguin
9b205e2f53 syncing current alpha 2023-04-06 09:52:17 -04:00
lindenkron
81eada963e Removed requestAudioOutputDevice padding. 2023-04-06 15:33:27 +02:00
lindenkron
1ceae44e69 Select/Button for Audio/Video Settings 2023-04-06 03:21:13 +02:00
lindenkron
17709f53c0 [Initial] Minor director finess changes. 2023-04-06 00:40:53 +02:00
steveseguin
97b50c3514 comms app meta/cmd key support 2023-03-26 17:06:52 -04:00
steveseguin
b246baf455 bug fixes mainly 2023-03-25 23:02:27 -04:00
steveseguin
43e577d25e sync with alpha; bug fixes; whip/whep sorta working 2023-03-19 21:47:54 -04:00
Steve Seguin
2b0db54eb9 Merge pull request #1015 from goinnovise/bug/page-load-twitch-box
Twitch + Chat Bugfix - Detect twitch URL properly
2023-03-16 18:09:01 -04:00
Loren Anderson
bb74ff43d9 Twitch + Chat Bugfix - Detect twitch URL properly 2023-03-16 14:56:27 -07:00
steveseguin
cef38ff7b8 fix scene 1 bug 2023-03-03 10:56:37 -05:00
steveseguin
1b58ee35d4 menu alignment fixed (try2) 2023-03-02 11:26:41 -05:00
steveseguin
85cd7056b7 undo; :P 2023-03-02 11:19:55 -05:00
steveseguin
600add80b9 css fix 2023-03-02 11:18:54 -05:00
Steve Seguin
03471cc5e2 Merge pull request #1013 from Andrew-Gallimore/develop
Fixed & Updated Guest Control-Button UI
2023-03-02 10:48:12 -05:00
steveseguin
d4ff81017d fix for hands 2023-03-02 10:40:09 -05:00
Steve Seguin
0e4d0b7eea Merge branch 'develop' into develop 2023-03-02 09:17:35 -05:00
steveseguin
2020366406 raise hand fix; highlight+ctrl 2023-03-01 17:41:26 -05:00
Steve Seguin
217ad59c44 Merge pull request #1010 from lindenkron/Overflow-main-page
Fix overflow for tiles main page.
2023-03-01 17:35:56 -05:00
Steve Seguin
dcd3347bce Merge pull request #1011 from lindenkron/fix-VDON-links
Better german word
2023-03-01 17:35:26 -05:00
Steve Seguin
43beb4b740 Merge pull request #1012 from TychoWerner/patch-1
Small FFMpeg typos
2023-03-01 17:35:02 -05:00
Andrew_Gallimore
95c0b775da Transfered UI changes to ShowDirector control-box 2023-02-28 20:13:59 -08:00
Andrew_Gallimore
e83b25bc62 Changed Grid to Flex & Wrapped Up 2023-02-26 15:19:54 -08:00
Tycho Werner
a1b7a452c8 Small FFMpeg typos 2023-02-26 12:30:02 +01:00
Andrew_Gallimore
d87353f5bc Fixed controlsGrid with css-grid 2023-02-25 18:10:30 -08:00
steveseguin
f75213cb9a minor sync 2023-02-24 19:00:01 -05:00
lindenkron
f51ee0c0a8 Better german word 2023-02-24 00:18:02 +01:00
lindenkron
eb7343b4c6 Fix overflow for tiles main page. 2023-02-23 21:48:53 +01:00
Steve Seguin
c02cb19078 Merge pull request #1009 from lindenkron/fix-VDON-links
Fix-VDON-links
2023-02-23 15:39:43 -05:00
lindenkron
8737b872dc German translation, url link change & more 2023-02-23 21:36:56 +01:00
lindenkron
c0277dd8ae Fix error 2023-02-21 22:49:50 +01:00
steveseguin
55e2106e59 recent fixes 2023-02-15 21:52:31 -05:00
Steve Seguin
f7c88fcce4 Merge pull request #1008 from lindenkron/Avatar-box-Darkmode
Darkmode for avatar box.
2023-02-06 12:19:31 -05:00
steveseguin
4002411a0d translation and removing some static css 2023-02-06 12:18:57 -05:00
lindenkron
de3dd398e8 Darkmode for avatar box. 2023-02-06 12:28:24 +01:00
steveseguin
385b574b36 &ad=1 fix; iframe.html tweak 2023-02-03 15:38:21 -05:00
steveseguin
17c6377773 clock=N 2023-02-01 16:38:29 -05:00
steveseguin
7032ba95c8 added record=false to index 2023-01-31 23:48:45 -05:00
steveseguin
6df72fb904 &record=false (session.record=false) blocks recording 2023-01-31 15:12:31 -05:00
steveseguin
26fbe42e77 removing support email by request 2023-01-31 13:45:59 -05:00
Steve Seguin
af37ce7b34 Merge pull request #1006 from lindenkron/message-box
Message box+ fix for Alpha
2023-01-30 15:05:58 -05:00
steveseguin
a1747fd7e4 pre-checkin before linds commit 2023-01-30 15:04:34 -05:00
Steve Seguin
3c04d4a947 Merge branch 'develop' into message-box 2023-01-30 14:57:53 -05:00
lindenkron
f47f0aff4c Message Box and additional required changes. Flex. 2023-01-30 19:25:54 +01:00
steveseguin
0b2fbf7462 just officially having develop marked as v23-beta 2023-01-23 11:42:12 -05:00
steveseguin
1513c4658d typo fix 2023-01-23 10:38:36 -05:00
steveseguin
8df78d1873 minor 2023-01-23 10:36:14 -05:00
steveseguin
d7f65a2abc deleting a duplicate folder 2023-01-23 10:32:57 -05:00
steveseguin
1938f5a267 clean up 2023-01-23 10:09:55 -05:00
steveseguin
4cfe930043 tweaked the php turn server code a bit to make it slightly easier 2023-01-23 10:05:26 -05:00
steveseguin
694d761a53 dev progress; synced to current alpha; untested 2023-01-23 06:53:16 -05:00
Steve Seguin
662a2ac8b8 prevents accidentally committing private info
turn server static auth file
2023-01-22 06:05:33 -05:00
Steve Seguin
92197d6033 Merge pull request #860 from Jumper78/feature/turn-server_with_static-auth-secret
turn-server with static-auth-secret in a separate php script
2023-01-22 06:01:49 -05:00
Steve Seguin
651c05b3eb Update and rename turn-credentials.php to turn-credentials-php.sample
renaming file extension to prevent php from being active by default.
2023-01-22 06:00:26 -05:00
Steve Seguin
fb94d7ca92 Merge branch 'develop' into feature/turn-server_with_static-auth-secret 2023-01-22 05:55:32 -05:00
steveseguin
b8f6642154 stats fix and mixer tweaks 2022-12-18 15:28:31 -05:00
Steve Seguin
50463c67e6 Update install.md 2022-12-11 16:16:09 -05:00
Steve Seguin
b0ca3f823a Update install.md 2022-12-11 16:14:05 -05:00
steveseguin
717e4e5457 fix for self deployment; shouldn't be showing wss 2022-12-11 15:20:55 -05:00
steveseguin
60822c80a8 bug fix for capped mobile bandwidth + &optimize=0 failing 2022-12-10 17:31:32 -05:00
Steve Seguin
87dc9e98a7 Update install.md 2022-12-10 13:08:32 -05:00
steveseguin
1939dc8550 minor bug fixes; &automute also added. currently what's on alpha 2022-12-08 10:30:16 -05:00
steveseguin
ea52a3d164 bug fixes and added more audio codecs 2022-12-03 05:02:39 -05:00
steveseguin
13d5e80d7c fix for deafen toggle 2022-11-19 19:44:40 -05:00
steveseguin
7906d77be7 comms app revert 2022-11-19 19:43:04 -05:00
steveseguin
6422754e88 more advanced audio options/stats 2022-11-19 05:41:08 -05:00
steveseguin
a19f91597f v22.7 production release 2022-11-18 13:12:39 -05:00
steveseguin
2bf7451fbe ios fix 2022-11-17 15:23:11 -05:00
steveseguin
c2556bbc0b new alias 2022-11-16 16:04:25 -05:00
steveseguin
744fcff54d fix for &ovb 2022-11-16 15:59:36 -05:00
steveseguin
facdbec1ab &hidehome and fixes 2022-11-16 02:33:40 -05:00
steveseguin
a95cc4bc4e buffer audio fix 2022-11-14 17:24:44 -05:00
Steve Seguin
99f174264f Update README.md 2022-11-09 23:49:40 -05:00
steveseguin
9a95a957c0 stats fix 2022-11-09 23:45:53 -05:00
steveseguin
25015a33a3 recent updates; comms app,etc 2022-10-27 08:22:45 -04:00
Steve Seguin
fbbc6c6c31 speedtest temp fix ; &label increased to 100char max 2022-10-04 01:03:32 -04:00
Steve Seguin
09467fac9a api boolean fixes 2022-10-01 15:36:22 -04:00
Steve Seguin
0573fb38ba v22.4 beta 2022-09-25 21:09:45 -04:00
Steve Seguin
a911e86b29 Merge pull request #992 from theprincy/develop
Develop
2022-09-17 16:20:56 -04:00
Steve Seguin
0615d16e65 Merge pull request #991 from theprincy/master
Update it.json
2022-09-17 16:18:57 -04:00
Notelseit
ecc82e7085 update language IT 2022-09-17 22:17:15 +02:00
Notelseit
0b170e6407 update language IT 2022-09-17 22:15:19 +02:00
Notelseit.com
955b65f8e4 Update it.json 2022-09-17 22:02:06 +02:00
Notelseit
42201df814 update language IT 2022-09-17 21:58:34 +02:00
Notelseit
766976a049 Update Language 2022-09-15 00:24:42 +02:00
Steve Seguin
4a545b92ce Update turnserver.md 2022-09-08 00:15:20 -04:00
Steve Seguin
99c98b7443 bug fixes; features; but echo-problems still 2022-09-06 06:02:29 -04:00
Steve Seguin
17ff60e17e Merge pull request #985 from lguima/homepage-description-pt-br
Add homepage description for pt-br
2022-08-25 21:10:18 -04:00
Lucas Guima
1e84530e41 Add homepage description for pt-br 2022-08-25 22:08:27 -03:00
Steve Seguin
8e3b5d79e3 Merge pull request #984 from lguima/vdo-ninja-rebranding-update
Update remaining old brand references
2022-08-25 20:58:37 -04:00
Lucas Guima
5b7e1b396b Update header title
Update GitHub repository link

Update Reddit link
2022-08-25 21:52:01 -03:00
Steve Seguin
494db979cd &contenthint and examples added; & pt-br 2022-08-25 17:10:48 -04:00
Steve Seguin
dbf1f286aa Merge pull request #983 from lguima/pt-br-translation
Translation for pt-br
2022-08-25 16:26:25 -04:00
Lucas Guima
3c89d98523 Order pt-br translation alphabetically by its keys 2022-08-25 17:14:15 -03:00
Lucas Guima
6489d44386 Translation for pt-br 2022-08-25 17:05:36 -03:00
Steve Seguin
e2a97da53e Update README.md 2022-08-25 15:39:04 -04:00
Steve Seguin
ee67c9e720 Update README.md 2022-08-25 15:37:55 -04:00
Lucas Guima
b6dd0b6638 Create a copy of translation file for pt-br 2022-08-25 10:32:00 -03:00
Steve Seguin
4e56e53a05 updated rtc.ninja translations 2022-08-24 12:00:37 -04:00
Steve Seguin
b6a8fe54a0 Update README.md 2022-08-23 16:03:37 -04:00
Steve Seguin
974408a4b3 updated translation files 2022-08-22 22:10:01 -04:00
Steve Seguin
dd0d164a62 minor fixes and a couple new URL options 2022-08-22 21:49:51 -04:00
Steve Seguin
e5f07b0b18 minor fixes and a couple new URL options 2022-08-22 21:48:18 -04:00
Steve Seguin
d30e1a1f35 old branding clean up 2022-08-09 12:40:21 -04:00
Steve Seguin
576a0dba33 old branding clean up 2022-08-09 12:37:00 -04:00
Steve Seguin
bd2b8567af bug fixes 2022-08-07 22:20:03 -04:00
Steve Seguin
a2f7b352a2 fix for control bar issue 2022-08-05 18:50:41 -04:00
Steve Seguin
58f646cb55 fix for &sendframes with iframe.html 2022-08-05 18:48:02 -04:00
Steve Seguin
184d206ee0 Merge pull request #976 from samthe13th/iframe-sandbox-v2
Refactor and redesign iframe.html
2022-08-05 18:32:36 -04:00
Sam MacKinnon
5d71b7cf88 Refactor and redesign iframe.html 2022-08-05 14:29:03 -07:00
SteveSeguin
7f191a0b93 loudness iframe api improved + &pushloudness and &getloudness added 2022-08-04 22:01:00 -04:00
SteveSeguin
e6dee5ca09 lowering volume of guests when solo talk enabled 2022-08-04 18:36:06 -04:00
Steve Seguin
fdf5ce970d versus.cam 1.0 support added 2022-08-04 17:23:46 -04:00
Steve Seguin
10fa7e0a2a fix for obs remote
&remote=xxx should work again
scroll added to remote pop up; lots of scenes supported now
2022-07-27 15:27:00 -04:00
Steve Seguin
a5c34cbbda Update README.md 2022-07-26 09:12:57 -04:00
Steve Seguin
2fe7b18025 Update README.md 2022-07-26 09:12:10 -04:00
Steve Seguin
ad784b03d0 Update README.md 2022-07-26 09:11:54 -04:00
Steve Seguin
b402dfe02d Update README.md 2022-07-26 09:08:10 -04:00
Steve Seguin
fc2d77671d Merge pull request #973 from steveseguin/steveseguin-patch-1
v22 updates
2022-07-26 08:19:53 -04:00
Steve Seguin
29e63296ac Add files via upload 2022-07-26 08:10:50 -04:00
Steve Seguin
0081e0ec39 Add files via upload 2022-07-26 08:09:51 -04:00
Steve Seguin
b6e1a4804c bug fixes for mobile devices 2022-07-03 05:40:33 -04:00
Steve Seguin
2fde8ebe27 v22 beta updates
Current progress updates on version 22 BETA.  This is not tested well enough yet to be considered production ready.

Please see v21-stable for a stable tested version.
2022-07-02 04:47:50 -04:00
Steve Seguin
e7084f288a Merge pull request #965 from frink/patch-1
AGPLv3 Compliance
2022-05-22 18:13:42 -04:00
M. Frink ~ Lemur
47a8b78cbd AGPLv3 Compliance 2022-05-22 17:09:29 -05:00
Steve Seguin
36f525f803 Update install.md 2022-05-21 08:08:14 -04:00
Steve Seguin
3772ca7a92 Update install.md 2022-05-21 07:56:21 -04:00
Steve Seguin
7f806ecb8d Update README.md 2022-05-21 07:28:27 -04:00
Steve Seguin
3629c6461b Update README.md 2022-05-21 07:24:19 -04:00
versuscam
54da0b9c9f Update main.css 2022-05-21 05:36:43 -04:00
versuscam
0b9f8c8976 Update main.js 2022-05-21 05:20:12 -04:00
versuscam
8275e35889 Update main.css 2022-05-21 05:17:21 -04:00
versuscam
b7ef4ed7b6 Update main.css 2022-05-21 05:04:47 -04:00
versuscam
a18e05534d Create CNAME 2022-05-21 04:55:16 -04:00
Steve Seguin
f7636dec56 Merge pull request #964 from steveseguin/v22-beta
V22 beta -> master sync up
2022-05-21 02:39:22 -04:00
Steve Seguin
0204888265 Add files via upload 2022-05-21 02:37:17 -04:00
Steve Seguin
4b56a573c6 Merge pull request #963 from steveseguin/steveseguin-patch-2
trying to merge language translations between versions
2022-05-21 02:36:45 -04:00
Steve Seguin
d041986fb7 trying to merge language translations between versions 2022-05-21 02:35:16 -04:00
Steve Seguin
82da4e54e9 Merge pull request #960 from steveseguin/steveseguin-patch-1
Add files via upload
2022-05-21 02:12:29 -04:00
Steve Seguin
c145547454 Add files via upload 2022-05-21 02:11:32 -04:00
Steve Seguin
2aa607e408 fix for the stats page 2022-05-14 20:08:27 -04:00
Steve Seguin
584cdbb740 Add files via upload 2022-05-14 19:32:57 -04:00
Steve Seguin
d1d0e5115a Add files via upload 2022-05-14 19:21:59 -04:00
Steve Seguin
9046bcdb38 dedicated new stats page added & pause button fix;
Lots of small changes, mainly to accommodate the in-development mixer and stats pages

improvements to the language support also added

This is definitely a beta commit, as there is a lot that is needing testing
2022-05-14 19:21:07 -04:00
Steve Seguin
b39bb1a36e Update README.md 2022-05-12 05:49:32 -04:00
Steve Seguin
2c3346573d Merge pull request #958 from jcalado/master
fix multiselect border
2022-05-09 16:54:58 -04:00
Steve Seguin
5f8afd1b3c Delete readme.md 2022-05-09 16:48:58 -04:00
Steve Seguin
4c37be6272 Delete readme.md 2022-05-09 16:48:43 -04:00
Joel Calado
7744b8bd9a fix multiselect border 2022-05-09 21:39:37 +01:00
Steve Seguin
5078c25bc2 Add files via upload 2022-05-06 18:41:51 -04:00
Steve Seguin
9756c1c3b8 Merge pull request #955 from steveseguin/obs.ninja-to-vdo.ninja
update pt translation
2022-05-06 18:41:12 -04:00
Steve Seguin
d297c15b88 Merge pull request #956 from steveseguin/translation-update
add missing strings from director page
2022-05-06 18:40:58 -04:00
Joel Calado
9e92ed7081 Update pt.json 2022-05-06 19:16:53 +01:00
Joel Calado
892e8d2413 translate &tips 2022-05-06 19:14:06 +01:00
Joel Calado
467ca38ea1 more missing stuff 2022-05-06 18:17:46 +01:00
Joel Calado
ece54843b2 add missing strings from director page
Enables translation for:

- room settings
- Guest placeholder titles
- "Customize" and "copy link" buttons
2022-05-06 16:31:40 +01:00
Joel Calado
aceac5c248 update pt translation 2022-05-06 15:52:09 +01:00
Steve Seguin
53f6420419 Merge pull request #953 from jcalado/master
Update PT translation
2022-05-04 12:10:30 -04:00
Joel Calado
4d74a1c071 Update pt.json 2022-05-04 08:51:24 +01:00
Joel Calado
3c71da468f Update pt.json 2022-05-04 08:49:13 +01:00
Joel Calado
6bbfbc5850 Update pt.json 2022-05-04 08:05:54 +01:00
Joel Calado
5144593a0a Update pt.json 2022-05-04 07:46:41 +01:00
Joel Calado
300dca913e Update pt.json 2022-05-04 06:48:39 +01:00
Joel Calado
8e55efbeb5 Update pt.json 2022-05-04 06:43:59 +01:00
Joel Calado
0fe9a2a19a Update pt.json 2022-05-03 17:03:10 +01:00
Joel Calado
dbbdde4e95 Update pt.json 2022-05-03 17:02:05 +01:00
Joel Calado
4708139f5c Update pt.json 2022-05-03 16:57:48 +01:00
Joel Calado
c5cadb9a73 Update pt.json 2022-05-03 16:53:11 +01:00
Steve Seguin
c1c7ee5a10 Merge pull request #951 from steveseguin/language-update
language update
2022-04-30 16:57:06 -04:00
Steve Seguin
78182472fc Add files via upload 2022-04-30 16:55:16 -04:00
Steve Seguin
d8b342d0e0 Merge pull request #950 from Mixgyt/patch-4
Update Spanish Languaje and small fixes
2022-04-30 12:57:17 -04:00
Cesar
e85a03cfef Update Spanish Languaje and small fixes
corrections in words and capitalization, in addition to translating new words
2022-04-30 10:55:27 -06:00
Steve Seguin
7fe8d5b01b bug fix; video fails to load after reconnect
issue was related to the new mixer logic added to allow director to switch between director and scene preview modes.  it was not related to connection logic, thankfully.

There may be further bugs though; please test.
2022-04-28 09:49:19 -04:00
Steve Seguin
6d2d71adae Add files via upload 2022-04-27 18:15:26 -04:00
Steve Seguin
2ec05236b0 Add files via upload 2022-04-27 18:15:01 -04:00
Steve Seguin
358b383e6b v22 beta
part 1
2022-04-27 18:14:34 -04:00
Steve Seguin
c79b20ce51 &notify creates chrome notification on user join
This commit is NOT tested yet; I've been messing around with some of the auto mixer code too.
2022-04-07 08:51:10 -04:00
Steve Seguin
1ce6238d51 audio stopping after device change fix 2022-04-04 07:08:11 -04:00
Steve Seguin
8808bf9510 Merge pull request #941 from baumannzone/patch-1
Update es.json
2022-04-04 00:49:00 -04:00
Steve Seguin
d86a721db5 Add files via upload
fix for iOS devices being stuck at low bitrate
2022-04-04 00:48:39 -04:00
Jorge Baumann
0d6c152649 Update es.json
Small Spanish fixes
2022-04-02 22:32:58 +02:00
Steve Seguin
a04a37fd5f Merge pull request #940 from steveseguin/v21-dev
v21.3
2022-04-01 04:27:39 -04:00
Steve Seguin
b975115e69 Add files via upload 2022-04-01 04:23:48 -04:00
Steve Seguin
f68ae33cad Add files via upload 2022-02-18 04:53:25 -05:00
Steve Seguin
60c20e2c82 Add files via upload 2022-02-18 04:52:58 -05:00
Steve Seguin
3665e2fc13 trying to fix disconnect / reconnection IRL issues 2022-02-18 04:52:29 -05:00
Steve Seguin
d170214cc8 Merge pull request #937 from steveseguin/v21-dev
V21 dev
2022-02-16 03:27:04 -05:00
Steve Seguin
e26ab5ff81 Add files via upload 2022-02-16 03:26:16 -05:00
Steve Seguin
cf2b97737e Add files via upload 2022-02-16 03:20:34 -05:00
Steve Seguin
7d45445b64 Delete manifest.json 2022-02-16 03:18:00 -05:00
Steve Seguin
17f7425828 Merge pull request #936 from steveseguin/v21-dev
V21.1 dev
2022-02-16 03:15:19 -05:00
Steve Seguin
5f1d2e78b5 file saving fix 2022-02-16 03:14:47 -05:00
Steve Seguin
5007bb029d Add files via upload 2022-02-16 03:13:46 -05:00
Steve Seguin
8e35995455 Merge pull request #935 from steveseguin/v21-dev
V21.0
2022-02-14 02:43:00 -05:00
Steve Seguin
cdcc9d4eb2 Update README.md 2022-02-14 01:54:27 -05:00
Steve Seguin
d645040d3b Add files via upload 2022-02-14 01:52:10 -05:00
Steve Seguin
dcfd10bbc7 Add files via upload 2022-02-14 01:51:10 -05:00
Steve Seguin
4f22088086 Add files via upload 2022-02-14 01:49:12 -05:00
Steve Seguin
75e4c13bf4 Add files via upload 2022-02-14 01:48:15 -05:00
Steve Seguin
1934df6517 Merge pull request #934 from steveseguin/v20.x-dev-patches
v20.7 (beta)
2022-02-13 03:15:46 -05:00
Steve Seguin
abc139ce05 Merge branch 'master' into v20.x-dev-patches 2022-02-13 03:14:16 -05:00
Steve Seguin
87f67ae1eb Add files via upload 2022-02-13 03:11:32 -05:00
Steve Seguin
c77c67361a Add files via upload 2022-02-13 03:10:14 -05:00
Steve Seguin
308ed7d598 Merge pull request #933 from nagaitsev/patch-3
Update ru.json
2022-02-13 03:09:14 -05:00
Steve Seguin
caf9ce943f Add files via upload 2022-02-13 03:08:34 -05:00
Steve Seguin
6e504a7f65 Add files via upload 2022-02-13 03:08:02 -05:00
nagaitsev
923237a521 Update ru.json 2022-02-10 02:41:02 +05:00
Steve Seguin
2411804868 auto-video-mute iOS video when loss of focus 2022-02-08 15:56:33 -05:00
Steve Seguin
4eac654d13 bitcut fix 2022-02-08 00:27:56 -05:00
Steve Seguin
94a6f5ff89 v20.6
bug fixes; couple minor features.  testing out PWA support
2022-02-07 12:40:59 -05:00
Steve Seguin
981841583a right click context menu options
&sspaused added also

-- minor polish bugs with the new features need to be worked out still
2022-01-29 14:57:01 -05:00
Steve Seguin
871b4c3808 Merge pull request #930 from steveseguin/v20.x-dev-patches
V20.4 merge
2022-01-24 09:15:14 -05:00
Steve Seguin
0b64e44e3b 20.4 2022-01-24 09:13:43 -05:00
Steve Seguin
df1f1c5e09 Add files via upload
fixed the highlight video button
2022-01-24 09:11:12 -05:00
Steve Seguin
6752212969 Add files via upload 2022-01-23 18:27:22 -05:00
Steve Seguin
ddf52dd4ec Add files via upload 2022-01-23 18:27:07 -05:00
Steve Seguin
d10fd3c7c5 fixes for iOS 2022-01-17 03:52:08 -05:00
Steve Seguin
92d8aa6004 Merge pull request #928 from Mixgyt/patch-3
Update spanish language
2022-01-16 22:07:25 -05:00
Cesar
369bf2046c Update spanish language 2022-01-16 20:58:14 -06:00
Steve Seguin
46b00c59f8 removing some debugging edits no longer 2022-01-15 04:32:09 -05:00
Steve Seguin
f96af0d543 v20.2 2022-01-15 04:11:33 -05:00
Steve Seguin
1641565a33 iOS unmuted fix; stats show on iOS 2022-01-14 12:50:23 -05:00
Steve Seguin
2f3fa8d5ae chunked saving fixed; &blindall; minor tweaks 2022-01-07 05:12:02 -05:00
Steve Seguin
c24dac0253 improved &chunked mode; other features
for experimentation
2022-01-03 19:17:22 -05:00
Jumper78
b6c966197a move turn-credentials request to index.html
main.js
remove the section with the request to the turn-credentials.php script

index.html
add and uncomment the request to the turn-credentials.php script
2021-05-19 10:23:03 +02:00
Jumper78
11ad42bf34 invalid variable names corrected
index.html
session.turn-mode changed to session.turnmode

main.js
session.turn-mode changed to session.turnmode
2021-05-11 18:46:38 +02:00
Jumper78
c559b6ad5f generation for turn-server with static-auth-secret in a separate php script
index.html
added a two config-lines that can simply be uncommented for activating the "twilio-mode" or the "php-credentials-mode"

turn-credentials.php (new file)
will generate username and password for a turn server with a static-auth-secret and will offer it in json-format

main.js
added a section that requests output from php-credentials.php and adds the username, password, stun-server and turn-server into the configuration
2021-05-11 07:57:35 +02:00
368 changed files with 253555 additions and 38842 deletions

269
.github/ci-validateTranslations.js vendored Normal file
View File

@@ -0,0 +1,269 @@
const fs = require("fs");
const path = require("path");
class JsonParser {
constructor(text, filePath) {
this.text = text;
this.filePath = filePath;
this.index = 0;
this.duplicateKeys = [];
}
parse() {
const value = this.parseValue([]);
this.skipWhitespace();
if (this.index !== this.text.length) {
this.throwError("Unexpected trailing content");
}
return { value, duplicateKeys: this.duplicateKeys };
}
parseValue(pathStack) {
this.skipWhitespace();
const char = this.peek();
if (char === "{") {
return this.parseObject(pathStack);
}
if (char === "[") {
return this.parseArray(pathStack);
}
if (char === "\"") {
return this.parseString();
}
if (char === "t") {
return this.parseLiteral("true", true);
}
if (char === "f") {
return this.parseLiteral("false", false);
}
if (char === "n") {
return this.parseLiteral("null", null);
}
if (char === "-" || this.isDigit(char)) {
return this.parseNumber();
}
this.throwError("Unexpected token");
}
parseObject(pathStack) {
this.consume("{");
this.skipWhitespace();
const result = {};
const keySet = new Set();
if (this.peek() === "}") {
this.index += 1;
return result;
}
while (true) {
this.skipWhitespace();
if (this.peek() !== "\"") {
this.throwError("Expected string key");
}
const key = this.parseString();
if (keySet.has(key)) {
this.duplicateKeys.push({ path: [...pathStack, key] });
} else {
keySet.add(key);
}
this.skipWhitespace();
this.consume(":");
const value = this.parseValue([...pathStack, key]);
result[key] = value;
this.skipWhitespace();
const next = this.peek();
if (next === "}") {
this.index += 1;
break;
}
this.consume(",");
}
return result;
}
parseArray(pathStack) {
this.consume("[");
this.skipWhitespace();
const result = [];
if (this.peek() === "]") {
this.index += 1;
return result;
}
let index = 0;
while (true) {
const value = this.parseValue([...pathStack, index]);
result.push(value);
index += 1;
this.skipWhitespace();
const next = this.peek();
if (next === "]") {
this.index += 1;
break;
}
this.consume(",");
}
return result;
}
parseString() {
this.consume("\"");
let result = "";
while (this.index < this.text.length) {
const char = this.text[this.index];
if (char === "\"") {
this.index += 1;
return result;
}
if (char === "\\") {
this.index += 1;
const escaped = this.text[this.index];
if (escaped === undefined) {
this.throwError("Unterminated escape sequence");
}
if (escaped === "\"" || escaped === "\\" || escaped === "/") {
result += escaped;
} else if (escaped === "b") {
result += "\b";
} else if (escaped === "f") {
result += "\f";
} else if (escaped === "n") {
result += "\n";
} else if (escaped === "r") {
result += "\r";
} else if (escaped === "t") {
result += "\t";
} else if (escaped === "u") {
const hex = this.text.slice(this.index + 1, this.index + 5);
if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
this.throwError("Invalid unicode escape");
}
result += String.fromCharCode(parseInt(hex, 16));
this.index += 4;
} else {
this.throwError("Invalid escape sequence");
}
this.index += 1;
continue;
}
if (char < " ") {
this.throwError("Unescaped control character in string");
}
result += char;
this.index += 1;
}
this.throwError("Unterminated string");
}
parseNumber() {
const remaining = this.text.slice(this.index);
const match = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(remaining);
if (!match) {
this.throwError("Invalid number");
}
this.index += match[0].length;
return Number(match[0]);
}
parseLiteral(literal, value) {
if (this.text.slice(this.index, this.index + literal.length) !== literal) {
this.throwError(`Expected ${literal}`);
}
this.index += literal.length;
return value;
}
skipWhitespace() {
while (this.index < this.text.length) {
const char = this.text[this.index];
if (char === " " || char === "\n" || char === "\r" || char === "\t") {
this.index += 1;
continue;
}
break;
}
}
peek() {
return this.text[this.index];
}
consume(expected) {
if (this.text[this.index] !== expected) {
this.throwError(`Expected '${expected}'`);
}
this.index += 1;
}
isDigit(char) {
return char >= "0" && char <= "9";
}
throwError(message) {
const { line, column } = this.getLineColumn(this.index);
const error = new Error(`${message} at ${this.filePath}:${line}:${column}`);
throw error;
}
getLineColumn(position) {
const slice = this.text.slice(0, position);
const lines = slice.split("\n");
return { line: lines.length, column: lines[lines.length - 1].length + 1 };
}
}
function formatPath(pathParts) {
return pathParts.reduce((acc, part) => {
if (typeof part === "number") {
return `${acc}[${part}]`;
}
return acc ? `${acc}.${part}` : part;
}, "");
}
function validateTranslationFiles() {
const translationsDir = path.join(__dirname, "..", "translations");
const files = fs
.readdirSync(translationsDir)
.filter((file) => file.endsWith(".json"))
.sort();
const errors = [];
for (const file of files) {
const filePath = path.join(translationsDir, file);
const content = fs.readFileSync(filePath, "utf8");
try {
const parser = new JsonParser(content, filePath);
const { duplicateKeys } = parser.parse();
if (duplicateKeys.length > 0) {
duplicateKeys.forEach((entry) => {
errors.push(
`Duplicate key '${formatPath(entry.path)}' in ${filePath}`
);
});
}
} catch (error) {
errors.push(error.message);
}
}
if (errors.length > 0) {
console.error("Translation validation failed:\n");
errors.forEach((error) => console.error(`- ${error}`));
process.exit(1);
}
console.log("Translation validation passed.");
}
validateTranslationFiles();

43
.github/workflows/static.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
# 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: ["develop"]
# 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 only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
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@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: '.'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -0,0 +1,29 @@
name: Validate translations
on:
push:
branches:
- develop
paths:
- "translations/*.json"
pull_request:
branches:
- develop
paths:
- "translations/*.json"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Validate translations
run: node .github/ci-validateTranslations.js

12
.gitignore vendored
View File

@@ -1,2 +1,12 @@
node_modules/
package-lock.json
package-lock.json
turn-credentials.php
.vscode/
.claude
.claude/
AGENTS.md
CLAUDE.md
cf/
wrangler.toml
package.json
docs.md

11
.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 4,
"useTabs": true,
"bracketSpacing": true,
"jsxBracketSameLine": false,
"arrowParens": "avoid",
"trailingComma": "none",
"printWidth": 10000
}

156
360.html Normal file
View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html>
<head>
<script src="./thirdparty/aframe.min.js"></script>
<script>
// This snippet of AFRAME code was taken from an AFRAME 360 video sample
// AFRAME code is MIT licenced - fork of lic here: https://github.com/steveseguin/aframe/tree/master#MIT-1-ov-file
// source code ref - https://github.com/steveseguin/aframe/blob/master/examples/js/hide-on-play.js
// source code ref - https://github.com/steveseguin/aframe/blob/master/examples/js/play-on-click.js
AFRAME.registerComponent('play-on-click', {
init: function () {
this.onClick = this.onClick.bind(this);
},
play: function () {
window.addEventListener('click', this.onClick);
},
pause: function () {
window.removeEventListener('click', this.onClick);
},
onClick: function (evt) {
var videoEl = this.el.getAttribute('material').src;
if (!videoEl) { return; }
this.el.object3D.visible = true;
videoEl.play();
}
});
AFRAME.registerComponent('hide-on-play', {
schema: {type: 'selector'},
init: function () {
this.onPlaying = this.onPlaying.bind(this);
this.onPause = this.onPause.bind(this);
this.el.object3D.visible = !this.data.playing;
},
play: function () {
if (this.data) {
this.data.addEventListener('playing', this.onPlaying);
this.data.addEventListener('pause', this.onPause);
}
},
pause: function () {
if (this.data) {
this.data.removeEventListener('playing', this.onPlaying);
this.data.removeEventListener('pause', this.onPause);
}
},
onPlaying: function (evt) {
this.el.object3D.visible = false;
},
onPause: function (evt) {
this.el.object3D.visible = true;
}
});
</script>
</head>
<body>
<a-scene>
<a-assets>
<video id="video"
loop
crossorigin="anonymous"
playsinline webkit-playsinline>
</video>
</a-assets>
<a-videosphere
rotation="0 -90 0" src="#video" play-on-click>
</a-videosphere>
<a-camera>
<a-entity
position="0 0 -1.5"
text="align:center;
width:6;
wrapCount:100;
color: white;
value: Click or tap to start video"
hide-on-play="#video">
</a-entity>
</a-camera>
</a-scene>
<iframe id="iframe" allow="cross-origin-isolated;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;screen-wake-lock;accelerometer;midi;geolocation;gyroscope;"></iframe>
<script>
// the following code is VDO.Ninja's code.
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
if (urlEdited !== window.location.search){
warnlog(window.location.search + " changed to " + urlEdited);
window.history.pushState({path: urlEdited.toString()}, '', urlEdited.toString());
}
var urlParams = new URLSearchParams(urlEdited);
var view = false;
if (urlParams.has("view") || urlParams.has("v")){
view = urlParams.get("view") || urlParams.get("v") || false;
}
var password = false;
if (urlParams.has("password") || urlParams.has("pw") || urlParams.has("p")){
password = urlParams.get("password") || urlParams.get("pw") || urlParams.get("p") || false;
if (password===false){
password = prompt("Please enter a password") || false;
}
}
if (password){
document.getElementById("iframe").src = "./?speakermuted&sendframes&manual&scale=100&ltb=80&bitrate=12000&cleanoutput&label=360_viewer&view="+view+"&password="+password;
} else {
document.getElementById("iframe").src = "./?speakermuted&sendframes&manual&scale=100&ltb=80&bitrate=12000&cleanoutput&label=360_viewer&view="+view
}
// Your existing JavaScript code for handling video frames
var media = {
tracks: {},
streams: {}
};
window.addEventListener('message', function (e) {
if (e.data.frame) {
if (!media.tracks[e.data.trackID]){
console.log("NEW");
media.tracks[e.data.trackID] = {};
media.tracks[e.data.trackID].generator = new MediaStreamTrackGenerator({kind:e.data.kind});
media.tracks[e.data.trackID].stream = new MediaStream([media.tracks[e.data.trackID].generator]);
media.tracks[e.data.trackID].frameWriter = media.tracks[e.data.trackID].generator.writable.getWriter();
media.tracks[e.data.trackID].frameWriter.write(e.data.frame);
if (!media.streams[e.data.streamID]){
media.streams[e.data.streamID] = document.getElementById("video");
media.streams[e.data.streamID].srcObject = media.tracks[e.data.trackID].stream;
} else {
if (e.data.kind=="video"){
media.streams[e.data.streamID].srcObject.getVideoTracks().forEach(trk=>{
media.streams[e.data.streamID].srcObject.removeTrack(trk);
});
} else if (e.data.kind=="audio"){
media.streams[e.data.streamID].srcObject.getAudioTracks().forEach(trk=>{
media.streams[e.data.streamID].srcObject.removeTrack(trk);
});
}
media.tracks[e.data.trackID].stream.getTracks().forEach(trk=>{
media.streams[e.data.streamID].srcObject.addTrack(trk);
});
}
} else {
media.tracks[e.data.trackID].frameWriter.write(e.data.frame);
}
}
}, false);
</script>
</body>
</html>

View File

@@ -4,7 +4,7 @@ To ensure the long-term viability of this project, and for the protection of its
# Contribution Policy
You are invited to digitally sign the CLA with the provided CLA Assissant service. You may also print, sign, scan, and then email the CLA to steve@seguin.email.
You are invited to digitally sign the CLA with the provided CLA Assissant service, with a link to sign it provided automatically after contributing to this Github project. You may also print, sign, scan, and then email the CLA to steve@seguin.email.
It is not required that you sign the CLA for every contribution. Once you execute a CLA, it is valid until the CLA agreement is meaningfully changed and requires updating.

120
README.md
View File

@@ -1,73 +1,110 @@
### ⚠ Notice! We've rebranded from OBS.Ninja to VDO.Ninja - still all else the same though ✨
OBS.Ninja links will start redirecting to VDO.Ninja automatically on January 14th, 2022. If there are issues, simply refresh both the sender and viewer-side links or rename your links from obs.ninja to vdo.ninja.
<img src="https://github.com/user-attachments/assets/8134f167-2ea5-42e8-9450-b7aed322b6b0" width="300" />
<img src="https://user-images.githubusercontent.com/2575698/124821455-bbfec580-df3c-11eb-9641-3d036cdd6c41.png" data-canonical-src="https://user-images.githubusercontent.com/2575698/124821455-bbfec580-df3c-11eb-9641-3d036cdd6c41.png" width="200" />
[![GitHub stars](https://img.shields.io/github/stars/steveseguin/vdoninja?style=social)](https://github.com/steveseguin/vdoninja)
[![GitHub forks](https://img.shields.io/github/forks/steveseguin/vdoninja?style=social)](https://github.com/steveseguin/vdoninja/fork)
[![GitHub release](https://img.shields.io/github/v/release/steveseguin/vdoninja?include_prereleases)](https://github.com/steveseguin/vdoninja/releases)
[![Discord](https://img.shields.io/discord/698324796546482177?color=7289DA&label=community&logo=discord&logoColor=white)](https://discord.vdo.ninja)
[![Share on Twitter](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Fgithub.com%2Fsteveseguin%2Fvdoninja)](https://twitter.com/intent/tweet?text=Check%20out%20VDO.Ninja%20-%20Peer-to-peer%20video%20streaming%20for%20OBS%20and%20more!&url=https%3A%2F%2Fgithub.com%2Fsteveseguin%2Fvdoninja)
## What is **VDO NINJA**
VDO.Ninja uses peer-to-peer technology to bring remote cameras into OBS or other studio software.
#### ⚠ Notice! We've rebranded from OBS.Ninja to VDO.Ninja - all else is staying the same ✨
In most cases, all video data is transferred directly from peer to peer, without needing to go through any video server. This results in high-quality video with super low latency. In a small number of cases, video data may go through an encrypted TURN server, which is used to facilitate peer connections when otherwise not possible.
VDO.Ninja is designed to allow content creators to produce real-time live shows using remote media streams. It can also turn smartphones into wireless webcams, with additional Virtualcam software.
## What is VDO.Ninja? 🚀
VDO.Ninja is freely available to use as a managed service over at https://vdo.ninja.
VDO.Ninja brings peer-to-peer technology to OBS and other studio software, enabling remote camera integration with:
For live support, please join our discord at https://discord.obs.ninja
Please see the sub-reddit added info: https://reddit.com/r/vdoninja
Also check out the FAQ for more info: https://github.com/steveseguin/vdoninja/wiki
* 🔒 Direct peer-to-peer video transfer in most cases
* ⚡ High-quality video with super low latency
* 💪 Director control room with group chat
* 📱 Smartphone wireless webcam capabilities
* 🌐 Supports WHIP/WHEP and self-hosted SFUs
* 🆓 Free software. Free managed services. Free support.
<img src="https://user-images.githubusercontent.com/2575698/120865595-56de3b80-c55c-11eb-8b98-60c59ae0f904.png" height="300" />
## How to use
I demo the basic usage of VDO.Ninja on YouTube: https://www.youtube.com/watch?v=6R_sQKxFAhg
## Quick Links 🔗
Here is a podcast series showing how to use different basic VDO.Ninja features, including macOS support: https://www.youtube.com/watch?v=XfSqufuoV74&list=PLWodc2tCfAH1l_LDvEyxEqFf42hOBKqQM
* 💬 [Live Support Discord](https://discord.vdo.ninja)
* 📚 [Documentation](https://docs.vdo.ninja)
* 🎯 [Subreddit](https://reddit.com/r/vdoninja)
* 🧯 [Backup Deployment](https://backup.vdo.ninja)
* 📱 Basic versions also available on [App Store](https://apps.apple.com/us/app/vdo-ninja/id1607609685) and [Play Store](https://play.google.com/store/apps/details?id=flutter.vdo.ninja)
And Here is another video series touching on some more advanced settings: https://www.youtube.com/watch?v=mQ1Jdhf5aYg&list=PL8VJWj2-XLFpFu3G35Hdm1nKZ2xn9_0_8
## How to Use 📝
Check the subreddit for added use cases, advanced features, and support. Advanced features includes high-quality audio modes, custom video resolutions, and more.
You can get started by just opening [VDO.Ninja](https://vdo.ninja/) in your browser and selecting *Add your Camera to OBS*.
* 🎥 [Basic Intro Video](https://www.youtube.com/watch?v=QaA_6aOP9z8&list=PLWodc2tCfAH1l_LDvEyxEqFf42hOBKqQM&index=1)
* 📺 [YouTube Video Tutorials](https://www.youtube.com/watch?v=mQ1Jdhf5aYg&list=PL8VJWj2-XLFpFu3G35Hdm1nKZ2xn9_0_8)
* 📖 [Getting Started Documentation](https://docs.vdo.ninja/getting-started)
Join the [Discord](https://discord.vdo.ninja) for community exhibitions, discussions, support, and feature updates.
## Alternative versions of VDO.Ninja
* 🪟 [Mixer App with custom layouts](https://vdo.ninja/mixer)
* 🏹 [WHIP/WHEP client](https://vdo.ninja/whip)
* 📈 [Sharable Whiteboard](https://vdo.ninja/whiteboard)
* 🕹️ [ESports Feed Manager](https://versus.cam)
* 🌃 [Alpha-version updated nightly](https://vdo.ninja/alpha)
## What's in this repo
This repo contains software for VDO.Ninja, including the HTML landing page for its Electron Capture app offering. A sample config file and instructions for setting up a TURN server (video relay server), is also provided. You may also find [the Wiki](https://github.com/steveseguin/vdoninja/wiki) for the project in this repo, which contains added information on how to use the software.
This repo contains the web client software for VDO.Ninja, along with many sample apps that leverage its IFRAME API. A sample config file and instructions for setting up an optional TURN video relay server is also provided here. The user documentation for VDO.Ninja itself is found at docs.vdo.ninja.
## How to Deploy this Repo
To use, just download and host the files on a HTTPS-enabled webserver. You may want to hide the .html extensions within your HTTP server as well, else the generated links will not work. See [here](https://github.com/steveseguin/vdoninja/blob/master/install.md) for added details and alternative install options.
VDO.Ninja is available as a free-to-use hosted service at https://vdo.ninja, so deploying is optional. If you do wish to self-deploy the service however, details are provided below.
Directions on how to deploy a TURN server are listed in the turnserver.md file. You may wish to do so, although not all use cases will not need one. Only about 10% of remote guests, those often connected via 4G LTE, will require a TURN server. While VDO.Ninja does host some TURN servers, they are quite expensive to operate and not really for private deployment use. If you are deploying your own version of VDO.Ninja, I'd ask you use your own TURN servers instead.
Hosting a private/personal deployment can be as simple as hosting the files in this repository on a HTTPS-enabled webserver. For a very simple method on how to do this, there's a video guide here: https://www.youtube.com/watch?v=uYLKkX2_flY
For more advanced users, you can see the [install.md](https://github.com/steveseguin/vdoninja/blob/master/install.md) file for alternative hosting options and more details on deploying additional system components. Limited technical support is provided for self-deployments, mainly due to how time-consuming such requests are, but the details to fully-deploy all required system components are provided in the install.md file.
If self-hosting, you might also wish to host your own video relay TURN server. Directions on how to deploy a TURN server are listed in the [turnserver.md](turnserver.md) file. Only about ~ 5% of remote guests usually will need a TURN server, often those connected via 4G LTE or those behind a strict firewall, but most other users don't need one. While VDO.Ninja does host some pubiic TURN servers, they are quite expensive to operate, so please try to avoid abusing if possible. If you are deploying your own version of VDO.Ninja, I'd ask you to use your own TURN servers if you are capable of doing so; it's understandable if you aren't able to though.
For users wishing to host VDO.Ninja offline (where no Internet is available), there's a repository with everything needed to deploy locally and offline here: https://github.com/steveseguin/offline_deployment. The offline version includes a Docker option, and there are some community-created Dockers available for online hosting. I may eventually offer an official Docker option designed for online users with heavier requirements, but I lack time and support to maintain such a project currently.
### Develop vs Release versions
The develop branch of this repo is a bit like the preview or nightly version of VDO.Ninja. It's intended to be functional, but it may not be that well tested, or there could be incomplete features. The develop version aligns closely with what is normally on vdo.ninja/alpha/, which is well suited for those wishing to submit code changes or to gain access to experimental new features. You can access a hosted version of the GitHub develop branch on Github pages here as well: https://steveseguin.github.io/vdo.ninja/
Release versions of VDO.Ninja have their own branches though. These latest release branch will be updated to fix bugs or critical issues as needed, but are otherwise unchanged. https://github.com/steveseguin/vdo.ninja/branches
Due to the nature of live video production, where unexpected changes to the app are not welcomed usually, I don't update https://vdo.ninja/ all that often. As well, constant updates to the primary hosted app makes supporting users challenging, as its hard to tell if an issue is with the code or with the user. For this reason, VDO.Ninja does infrequent updates to the primary hosted production version. Users wanting newer features, or who have greater risk tolerance, should use alpha version at https://vdo.ninja/alpha/
## Server side / API software
Since VDO.Ninja uses peer-2-peer technology, video connections are made directly between viewer and publisher in 90% of cases. Hosting a TURN server yourself may help improve performance, but less than 1% of users will see any benefit of this. Details on how to deploy a TURN server are provided. For those capable of hosting their own TURN server, that would be appreciated if possible, as TURN servers are the only real cost incurred by VDO.Ninja at present. (other than time, of course)
Since VDO.Ninja uses peer-2-peer technology, video connections are made directly between viewer and publisher in 95% of cases. Hosting a TURN server yourself may help improve performance, but very few users will see an improvement to video quality by using one; most users will find using them harmful. They also will not help lower bandwidth usage or CPU usage, so generally you wish to avoid using them if possible.
Other than TURN servers, VDO.Ninja also uses public STUN servers and a hosted handshake server. These are used to facilitate the initial setup of peer connections and are generally not required after a peer connection is established. These servers are free to access and use, even for private deployments. As of Version 17.3 of VDO.Ninja, you can host your own handshake server or use a third-party managed one (such as piesocket.com); please see details here: https://github.com/steveseguin/websocket_server
Details on how to deploy a TURN server are provided; see: [turnserver.md](turnserver.md). For those capable of hosting their own TURN server, that would be appreciated if possible, as TURN servers are the largest cost incurred by VDO.Ninja at present. (other than time, of course)
Development builds of VDO.Ninja may include debugging software, but in-production releases have this removed. Double check to ensure "console.re" debugging is disabled before deployment, just to be safe.
Other than TURN servers, VDO.Ninja also uses public STUN servers and a hosted handshake server. These are used to facilitate the initial setup of peer connections and are generally not required after a peer connection is established. These servers are free to access and use, even for private deployments. You can host and customize your own handshake server as needed; please see details here: https://github.com/steveseguin/websocket_server
A design goal of VDO.Ninja is to be serverless and we are 99% of the way there. This design objective ensures VDO.Ninja can be offered for free, along with providing increased levels of security and privacy.
A design goal of VDO.Ninja is to be serverless and we are near 99% of the way there. This design objective ensures VDO.Ninja can be offered for free, along with providing increased levels of security and privacy.
## Issues? problems? Not working?
Please see the sub-reddit for more support: https://reddit.com/r/vdoninja
Join me and the community on Discord for support and more: https://discord.vdo.ninja. You can email me at steve@seguin.email for more urgent support or with other other inquiries if required.
Also check out the FAQ for common answers: https://github.com/steveseguin/vdoninja/wiki
The sub-Reddit is available at, https://reddit.com/r/vdoninja. I will often offer a single-message response to support questions posted there, but for deeper discussion, join the Discord.
If urgent, join me on discord: https://discord.gg/EksyhGA or email me at steve@seguin.email
Also check out the FAQ for common answers: https://docs.vdo.ninja or view recent product updates at: https://updates.vdo.ninja
I maintain a Youtube playlist with VDO.Ninja related content I create at https://www.youtube.com/watch?v=vLpRzMjUDaE&list=PLWodc2tCfAH1WHjl4WAOOoRSscJ8CHACe, however Youtube is full of community-created guides that are worth checking out.
## Related Projects
### VDO.Ninja's Electron Capture:
A better way to perform "Window Capturing" on desktop if OBS Browser Sources fails you. A downloadable tool designed to enhance VDO.Ninja.
https://github.com/steveseguin/electroncapture
A better way to perform "Window Capturing" on desktop if OBS Browser Sources fails you. A downloadable tool designed to enhance VDO.Ninja, but has been expanded to have additional functionality for content creators in general
[https://github.com/steveseguin/electroncapture](https://github.com/steveseguin/electroncapture)
### Social Stream Ninja
A free Chrome extension (also a Standalone app version is available now) that lets you stream and feature chat comments from Youtube, Twitch, Facebook, and more. Featured comments will appear directly in OBS or VMix as an overlay, or as a stream list of comments. It also includes a dock for more advanced function, such as text-to-speech, LLM bots, sentiment analysis, and saving messages to disk. No chroma-keying needed and the styling is pretty easy to customize without needing to modify the Chrome extension itself.
[http://socialstream.ninja](http://socialstream.ninja)
### Rasbperry Ninja
Use a Linux system, Raspberry Pi, Nvidia Jetson, Mac, and even Windows PC (WSL) to publis or view WebRTC video using Gstreamer and Python; no browser needed . This project can use the system's local hardware encoder to enable high resolution video and even accelerated AV1 encoding. Support for USB, CSI, and HDMI video sources is available, along with options to pass-thru sources without transcoding. OpenCV-friendly, for low-latency computer vision and machine learning applications.
[http://raspberry.ninja](https://github.com/steveseguin/raspberry_ninja)
### CAPTION.Ninja
A free AI-based closed-captioning tool to add speech-to-text overlays to OBS Studio. It's browser-based with an easy OBS or VMix integration. Developed by Steve as well! https://caption.ninja
### Chat.Overlay.Ninja
A free Chrome extension that lets you select Youtube Live Chat comments, with those comments then appearing directly in OBS or VMix as an overlay. No chroma-keying needed and the styling is pretty easy to customize without needing to modify the Chrome extension itself.
http://chat.overlay.ninja/
### Steves.app:
A website designed to also work with VDO.Ninja as a Broadcasting tool. Share your webcam, window, desktop, or video file with friends and family. Peer-2-peer, so privacy can be maintained, but you can also list your broadcasts for others to watch.
https://steves.app/
A free AI-based closed-captioning tool to add speech-to-text overlays to OBS Studio. It's browser-based with an easy OBS or VMix integration. Developed by Steve as well!
[https://caption.ninja](https://caption.ninja)
## Privacy
I try to avoid data collection whenever possible and video streams are generally designed to be private, but use at your own risk. It is best to not share links created with VDO.Ninja with those you do not trust. I've provided instructions on how to deploy a TURN server if IP-address privacy is an issue for you. See: [turnserver.md](turnserver.md)
@@ -76,6 +113,8 @@ https://vdo.ninja may unavoidably use cookies that are exempt from EU laws of re
Additional security features are being added weekly on request. Please ask about these options if added security and privacy are requirements for you.
Please see: [Terms of Service](https://docs.vdo.ninja/help/privacy-and-security-details/vdo.ninja-terms-of-service) | [Privacy Policy](https://docs.vdo.ninja/help/privacy-and-security-details).
## Feedback
Ideas, feedback, bugs, etc -- all welcomed. I'm dumping many of my ideas as issues into Github. Feedback is typically most welcomed via Email or Discord.
@@ -83,4 +122,9 @@ Ideas, feedback, bugs, etc -- all welcomed. I'm dumping many of my ideas as iss
VDO.Ninja is available as 'mostly' open-source; please see the LICENCE.md file for details.
## Credit
Thank you to everyone who has helped support this project so far. From the moderators, volunteers helping with support, those contributing media assets, the project sponsors, those reporting issues, those offering feedback, and any code submissions.
Thank you to everyone who has helped support this project so far. From the moderators, volunteers helping with support, those contributing media assets, the project sponsors, those reporting issues, those offering feedback, and any code submissions.
## Contributors of this repo
<a href="https://github.com/steveseguin/vdoninja/graphs/contributors">
<img src="https://contrib.rocks/image?repo=steveseguin/vdoninja" />
</a>

848
auth-client.js Normal file
View File

@@ -0,0 +1,848 @@
/* VDO.Ninja Authentication Client Integration */
// Configuration
const AUTH_SERVICE_URL = 'https://vdo-ninja-auth-service.vdo.workers.dev'; // Change for local dev: http://localhost:8787
// Authentication state
session.authMode = false;
session.requireAuth = false;
session.authToken = null;
session.authUser = null;
session.authStreamMapping = {};
session.handleToStream = {};
// Initialize authentication
async function initAuthentication() {
// Check URL parameters for universal token first
if (urlParams.has("universaltoken")) {
session.universalToken = urlParams.get("universaltoken");
session.authMode = true;
console.log('Universal token detected:', session.universalToken);
// Universal tokens bypass auth requirement for viewing
if (session.view || session.scene || session.solo) {
session.requireAuth = false;
console.log('Auth requirement bypassed for viewing');
}
}
// Check URL parameters
if (urlParams.has("auth") || urlParams.has("requireauth")) {
session.authMode = true;
session.requireAuth = urlParams.has("requireauth");
// Check for existing auth token in localStorage
const storedToken = localStorage.getItem('vdo_auth_token');
if (storedToken) {
try {
// Validate token is still valid
const payload = JSON.parse(atob(storedToken.split('.')[1]));
if (payload.exp > Date.now() / 1000) {
session.authToken = storedToken;
await populateUserInfo();
} else {
localStorage.removeItem('vdo_auth_token');
}
} catch (e) {
localStorage.removeItem('vdo_auth_token');
}
}
// Check for auth token in URL (after OAuth redirect)
if (urlParams.has("authtoken")) {
session.authToken = urlParams.get("authtoken");
localStorage.setItem('vdo_auth_token', session.authToken);
// Clean URL
const url = new URL(window.location.href);
url.searchParams.delete('authtoken');
window.history.replaceState({}, document.title, url.toString());
await populateUserInfo();
}
// Check if we need to verify room requirements
if (!session.authToken && session.authMode && (urlParams.has("room") || urlParams.has("roomid") || urlParams.has("r"))) {
const roomId = urlParams.get("room") || urlParams.get("roomid") || urlParams.get("r");
if (roomId) {
// Check if this room requires auth
try {
const roomInfo = await checkRoomAccess(roomId, urlParams.has("director") || urlParams.has("dir"));
if (roomInfo.requiresAuth) {
session.requireAuth = true;
}
} catch (e) {
console.log('Could not check room requirements:', e);
}
}
}
// Show auth UI if required and not authenticated
if (!session.authToken && (session.requireAuth || session.director)) {
// If the page is in auth mode or the director is attempting to use auth,
// encourage sign-in proactively.
showAuthUI();
}
}
}
// Show authentication UI
function showAuthUI(options = {}) {
const authContainer = document.createElement('div');
authContainer.id = 'auth-container';
authContainer.innerHTML = `
<div class="auth-modal">
<h2>Sign in to VDO.Ninja</h2>
<p>${options.message || 'Sign in to claim your personal stream ID and enable advanced features'}</p>
<div class="auth-buttons">
<button onclick="socialSignIn('google')" class="auth-button google">
<img src="./media/google.png" alt="Google">
Sign in with Google
</button>
<button onclick="socialSignIn('discord')" class="auth-button discord">
<img src="./media/discord.png" alt="Discord">
Sign in with Discord
</button>
<button onclick="socialSignIn('twitch')" class="auth-button twitch">
<img src="./media/twitch.png" alt="Twitch">
Sign in with Twitch
</button>
</div>
${(!session.requireAuth && !options.requireAuth) ? '<button onclick="skipAuth()" class="skip-auth">Continue without signing in</button>' : ''}
</div>
`;
document.body.appendChild(authContainer);
}
// Social sign-in handler
function socialSignIn(provider) {
const returnUrl = encodeURIComponent(window.location.href);
window.location.href = `${AUTH_SERVICE_URL}/auth/${provider}?returnUrl=${returnUrl}`;
}
// Skip authentication
function skipAuth() {
const authContainer = document.getElementById('auth-container');
if (authContainer) {
authContainer.remove();
}
session.authSkipped = true;
}
// Populate user info from auth token
async function populateUserInfo() {
if (!session.authToken) return;
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/user/info`, {
headers: { 'Authorization': `Bearer ${session.authToken}` }
});
if (response.ok) {
const userInfo = await response.json();
session.authUser = userInfo;
// Auto-populate label if not set
if (!session.label && userInfo.displayName) {
session.label = userInfo.displayName;
if (document.getElementById("label_input")) {
document.getElementById("label_input").value = session.label;
}
}
// Auto-populate avatar if not set
if (!session.avatar && userInfo.avatar) {
session.avatar = userInfo.avatar;
updateAvatarDisplay();
}
// Store user handle
session.userHandle = userInfo.userHandle;
// Show user info in UI
showUserInfo(userInfo);
}
} catch (e) {
console.error("Failed to get user info:", e);
}
}
// Show user info in UI
function showUserInfo(userInfo) {
const existingDisplay = document.getElementById('user-info-display');
if (existingDisplay) {
existingDisplay.remove();
}
const userDisplay = document.createElement('div');
userDisplay.id = 'user-info-display';
userDisplay.className = 'user-info-display';
userDisplay.innerHTML = `
<img src="${userInfo.avatar || './media/default-avatar.png'}" alt="${userInfo.displayName}">
<div class="user-details">
<div class="user-name">${userInfo.displayName}</div>
<div class="user-handle">${userInfo.userHandle}</div>
</div>
`;
// Add to appropriate location based on current view
const targetElement = document.querySelector('.header-container') || document.querySelector('.container');
if (targetElement) {
targetElement.insertBefore(userDisplay, targetElement.firstChild);
}
}
// Assign authenticated stream ID
async function assignAuthStream() {
if (!session.authToken || session.authStreamAssigned) return;
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/assign`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
roomId: session.roomid || 'lobby',
deviceLabel: session.streamID || 'camera',
useEncryption: false // Disabled for now until fully tested
})
});
if (response.ok) {
const assignment = await response.json();
// Store original stream ID
session.originalStreamID = session.streamID;
// Use assigned stream ID
session.streamID = assignment.streamId;
session.streamSecret = assignment.streamSecret;
session.authStreamAssigned = true;
console.log("Assigned authenticated stream:", assignment.streamId);
// Update any UI showing stream ID
updateStreamIDDisplay();
}
} catch (e) {
console.error("Failed to assign auth stream:", e);
}
}
// Generate stream authentication signature
async function generateStreamSignature() {
if (!session.streamSecret) return null;
const timestamp = Date.now();
const message = `${session.streamID}:${timestamp}`;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(session.streamSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
const hexSignature = Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return {
streamId: session.streamID,
userHandle: session.userHandle,
timestamp: timestamp,
signature: hexSignature
};
}
// Validate incoming stream authentication
async function validateStreamAuth(streamId, authData) {
if (!session.authToken || !authData) return true;
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/verify`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
streamId: streamId,
auth: authData
})
});
if (response.ok) {
const result = await response.json();
if (result.valid && result.userInfo) {
// Store user info for this stream
session.authStreamMapping[streamId] = result.userInfo;
// Update UI if this is a director view
if (session.director) {
updateStreamDisplay(streamId, result.userInfo);
}
}
return result.valid;
}
} catch (e) {
console.error("Stream validation failed:", e);
}
return false;
}
// Resolve view handles (e.g., @johndoe) to stream IDs
async function resolveViewHandles(viewList) {
if (!session.authToken) return viewList;
const resolved = [];
for (const target of viewList) {
if (target.startsWith('@')) {
// User handle - resolve to current stream
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/user/${target}`, {
headers: { 'Authorization': `Bearer ${session.authToken}` }
});
if (response.ok) {
const data = await response.json();
if (data.currentStreamId) {
resolved.push(data.currentStreamId);
// Store mapping for UI
session.handleToStream[target] = data;
}
}
} catch (e) {
console.error(`Failed to resolve handle ${target}:`, e);
}
} else {
resolved.push(target);
}
}
return resolved;
}
// Check room access
async function checkRoomAccess(roomIdOrAlias, isDirector = false) {
console.log('Checking room access for:', roomIdOrAlias, 'with universal token:', session.universalToken);
const response = await fetch(`${AUTH_SERVICE_URL}/api/room/access`, {
method: 'POST',
headers: {
'Authorization': session.authToken ? `Bearer ${session.authToken}` : '',
'Content-Type': 'application/json'
},
body: JSON.stringify({
room: roomIdOrAlias,
isDirector: isDirector,
universalToken: session.universalToken || null
})
});
const data = await response.json();
console.log('Room access response:', response.status, data);
// Handle room not found case
if (response.status === 404 && data && data.error === 'Room not found') {
// In auth mode, non-existent rooms can be created by authenticated users
if (session.authToken) {
// Allow authenticated users to proceed - room will be created on first join
return {
roomId: roomIdOrAlias,
alias: roomIdOrAlias,
displayName: roomIdOrAlias,
requiresAuth: false,
hasAccess: true,
isNew: true
};
} else {
// Require auth to create new rooms
return {
alias: roomIdOrAlias,
displayName: roomIdOrAlias,
requiresAuth: true,
hasAccess: false,
accessDenied: true,
denialReason: 'Sign in to create or join this room'
};
}
}
return data;
}
// Join room with authentication
async function joinRoomWithAuth(roomIdOrAlias) {
// If director is using auth mode but not signed in yet, force sign in first
if (session.director && session.authMode && !session.authToken && !session.universalToken) {
const roomLabel = roomIdOrAlias || 'this room';
showAuthUI({
message: `Sign in to manage "${roomLabel}"`,
requireAuth: true
});
return false;
}
// If we have a universal token, validate it first
if (session.universalToken) {
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/room/validate-universal`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
token: session.universalToken,
roomId: roomIdOrAlias
})
});
if (response.ok) {
const result = await response.json();
if (result.valid) {
// Universal token is valid, bypass normal auth
session.roomid = roomIdOrAlias;
return true;
}
}
} catch (e) {
console.error('Failed to validate universal token:', e);
}
}
const roomInfo = await checkRoomAccess(roomIdOrAlias, session.director);
if (roomInfo.requiresAuth && !session.authToken && !session.universalToken) {
if (session.authSkipped) {
// User already chose to skip auth, show access denied instead of auth UI
showAccessDeniedUI({
...roomInfo,
denialReason: 'This room requires authentication. Please reload the page and sign in to join.',
requestAccessUrl: null
});
return false;
} else {
// First time seeing auth requirement for this room
const displayLabel = roomInfo.displayName || roomInfo.alias || roomIdOrAlias || roomInfo.roomId || 'this room';
showAuthUI({
message: `Sign in to join "${displayLabel}"`,
requireAuth: true
});
return false;
}
}
if (roomInfo.accessDenied) {
showAccessDeniedUI(roomInfo);
return false;
}
// Important: For auth rooms, we need to use the original alias for hashing
// The auth service tracks by the real room ID, but VDO uses the alias
if (roomInfo.alias && roomInfo.alias === roomIdOrAlias) {
// User provided the alias, keep using it
session.roomid = roomIdOrAlias;
} else if (roomInfo.roomId === roomIdOrAlias) {
// User provided the real room ID
session.roomid = roomInfo.alias || roomIdOrAlias;
} else {
// Default case
session.roomid = roomInfo.alias || roomInfo.roomId;
}
session.roomAlias = roomInfo.alias;
session.realRoomId = roomInfo.roomId;
return true;
}
// Show access denied UI
function showAccessDeniedUI(roomInfo) {
const modal = document.createElement('div');
modal.id = 'auth-container';
modal.innerHTML = `
<div class="auth-modal access-denied-modal">
<h3>Access Denied</h3>
<p>${roomInfo.denialReason}</p>
${roomInfo.requestAccessUrl ?
`<button onclick="requestRoomAccess('${roomInfo.roomId}')">Request Access</button>` :
'<button onclick="window.location.reload()">Go Back</button>'
}
</div>
`;
document.body.appendChild(modal);
}
// Request room access
async function requestRoomAccess(roomId) {
if (!session.authToken) {
showAuthUI({ message: 'Sign in to request access' });
return;
}
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/room/request-access/${roomId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.authToken}`
}
});
if (response.ok) {
alert('Access request sent! The room owner will review your request.');
document.getElementById('auth-container').remove();
}
} catch (e) {
console.error('Failed to request access:', e);
}
}
// Update stream display with user info
function updateStreamDisplay(streamId, userInfo) {
// Update control box if it exists
const controlBox = document.getElementById(`controls_${streamId}`);
if (controlBox && userInfo) {
const header = controlBox.querySelector('.header');
if (header && !header.querySelector('.user-auth-badge')) {
const badge = document.createElement('div');
badge.className = 'user-auth-badge';
badge.innerHTML = `
<img src="${userInfo.avatar}" alt="${userInfo.displayName}">
<span class="user-handle">${userInfo.userHandle}</span>
<span class="user-provider ${userInfo.provider}">${userInfo.provider}</span>
`;
header.appendChild(badge);
}
}
// Update any labels showing stream ID
const labels = document.querySelectorAll(`[data-stream-id="${streamId}"]`);
labels.forEach(label => {
if (userInfo && !label.dataset.updated) {
label.dataset.updated = 'true';
label.textContent = userInfo.displayName || userInfo.userHandle;
}
});
}
// Update avatar display
function updateAvatarDisplay() {
if (session.avatar) {
// Update any avatar displays in the UI
const avatarElements = document.querySelectorAll('.avatar-display');
avatarElements.forEach(el => {
el.src = session.avatar;
});
}
}
// Update stream ID display
function updateStreamIDDisplay() {
// Update any UI elements showing the stream ID
const streamIdElements = document.querySelectorAll('.stream-id-display');
streamIdElements.forEach(el => {
el.textContent = session.originalStreamID || session.streamID;
});
}
// Resolve any stream ID (encrypted or not) through auth service
async function resolveStream(streamId) {
if (!session.authToken && !session.universalToken) {
return { error: 'Not authenticated' };
}
try {
const headers = {
'Content-Type': 'application/json'
};
if (session.authToken) {
headers['Authorization'] = `Bearer ${session.authToken}`;
}
const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/resolve`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
streamId: streamId,
roomId: session.roomid,
universalToken: session.universalToken
})
});
if (response.ok) {
const data = await response.json();
return data;
} else if (response.status === 403) {
return { error: 'Access denied' };
} else if (response.status === 404) {
return { error: 'Stream not found' };
}
} catch (e) {
console.error('Failed to resolve stream:', e);
return { error: 'Failed to resolve stream' };
}
return { error: 'Unknown error' };
}
// Get encryption key for viewing a stream
async function getStreamKey(streamId) {
if (!session.authToken) return null;
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/key`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
streamId: streamId,
roomId: session.roomid
})
});
if (response.ok) {
const data = await response.json();
return data;
}
} catch (e) {
console.error('Failed to get stream key:', e);
}
return null;
}
// Decrypt stream ID using XOR cipher
async function decryptStreamId(encryptedId, key) {
// Add padding if needed
const base64 = encryptedId
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(encryptedId.length + (4 - encryptedId.length % 4) % 4, '=');
const encrypted = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const keyData = new TextEncoder().encode(key);
const decrypted = new Uint8Array(encrypted.length);
for (let i = 0; i < encrypted.length; i++) {
decrypted[i] = encrypted[i] ^ keyData[i % keyData.length];
}
return new TextDecoder().decode(decrypted);
}
// Heartbeat to keep stream active
function startAuthHeartbeat() {
if (!session.authToken || !session.streamID) return;
setInterval(async () => {
if (session.authToken && session.streamID && session.authStreamAssigned) {
try {
await fetch(`${AUTH_SERVICE_URL}/api/stream/heartbeat`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
streamId: session.streamID,
roomId: session.roomid || 'lobby'
})
});
} catch (e) {
console.error('Heartbeat failed:', e);
}
}
}, 30000); // Every 30 seconds
}
// Create a universal token for view/scene links
async function createUniversalToken() {
if (!session.authToken || !session.roomid) {
console.error('Must be authenticated and in a room to create universal token');
return null;
}
try {
console.log('Creating universal token for room:', session.roomid);
const response = await fetch(`${AUTH_SERVICE_URL}/api/room/universal-token`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
roomId: session.roomid,
description: 'View/Scene access token'
})
});
if (response.ok) {
const data = await response.json();
session.universalViewToken = data.token;
console.log('Created universal token:', data.token);
// Update all existing solo links
updateAllSoloLinks();
return data.token;
} else {
console.error('Failed to create universal token:', response.status);
}
} catch (e) {
console.error('Failed to create universal token:', e);
}
return null;
}
// Update all solo link displays with new token
function updateAllSoloLinks() {
// Update all solo link inputs and displays
document.querySelectorAll('[data-sololink]').forEach(ele => {
const uuid = ele.getAttribute('data--u-u-i-d');
if (uuid && session.rpcs[uuid]) {
const soloLink = soloLinkGenerator(session.rpcs[uuid].streamID, false);
if (ele.tagName === 'INPUT') {
ele.value = soloLink;
} else if (ele.tagName === 'A') {
ele.href = soloLink;
ele.innerText = soloLink;
}
}
});
// Update director's own solo link if present
const directorLink = document.querySelector('#grabDirectorSoloLink');
if (directorLink && session.streamID) {
const soloLink = soloLinkGenerator(session.streamID, true);
directorLink.dataset.raw = soloLink;
directorLink.href = soloLink;
directorLink.innerText = soloLink;
}
// Update solo links in control boxes
document.querySelectorAll('.soloLink').forEach(ele => {
if (ele.getAttribute('value')) {
const baseUrl = ele.getAttribute('value');
// Extract stream ID from the base URL
const match = baseUrl.match(/[?&]view=([^&]+)/);
if (match && match[1]) {
const streamId = match[1];
const soloLink = soloLinkGenerator(streamId, false);
ele.href = soloLink;
ele.innerHTML = soloLink;
}
}
});
}
// Update room settings (access mode, allowlist)
async function updateRoomSettings(roomId, settings) {
if (!session.authToken) {
console.error('Must be authenticated to update room settings');
return null;
}
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/room/settings/${roomId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${session.authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
});
if (response.ok) {
const data = await response.json();
console.log('Room settings updated');
return data;
} else {
console.error('Failed to update room settings:', response.status);
}
} catch (e) {
console.error('Failed to update room settings:', e);
}
return null;
}
// Get pending access requests for a room
async function getRoomAccessRequests(roomId) {
if (!session.authToken) {
console.error('Must be authenticated to get access requests');
return [];
}
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/room/requests/${roomId}`, {
headers: {
'Authorization': `Bearer ${session.authToken}`
}
});
if (response.ok) {
return await response.json();
}
} catch (e) {
console.error('Failed to get access requests:', e);
}
return [];
}
// Approve or deny an access request
async function handleAccessRequest(roomId, userId, action) {
if (!session.authToken) {
console.error('Must be authenticated to handle access requests');
return false;
}
try {
const response = await fetch(`${AUTH_SERVICE_URL}/api/room/request/${roomId}/${userId}/${action}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.authToken}`
}
});
return response.ok;
} catch (e) {
console.error('Failed to handle access request:', e);
}
return false;
}
// Export functions for use in main VDO.Ninja code
window.vdoAuth = {
init: initAuthentication,
assignStream: assignAuthStream,
generateSignature: generateStreamSignature,
validateStream: validateStreamAuth,
resolveHandles: resolveViewHandles,
checkRoomAccess: checkRoomAccess,
joinRoom: joinRoomWithAuth,
startHeartbeat: startAuthHeartbeat,
getStreamKey: getStreamKey,
decryptStreamId: decryptStreamId,
resolveStream: resolveStream,
createUniversalToken: createUniversalToken,
updateRoomSettings: updateRoomSettings,
getRoomAccessRequests: getRoomAccessRequests,
handleAccessRequest: handleAccessRequest
};

342
auth-styles.css Normal file
View File

@@ -0,0 +1,342 @@
/* Authentication UI Styles */
/* Auth Container */
#auth-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.auth-modal {
background: var(--main-bg, #1a1a1a);
padding: 2rem;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 90%;
animation: authModalSlide 0.3s ease-out;
}
@keyframes authModalSlide {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.auth-modal h2 {
margin: 0 0 0.5rem 0;
color: var(--text-color, #fff);
font-size: 1.5rem;
text-align: center;
}
.auth-modal p {
color: var(--text-color-secondary, #aaa);
text-align: center;
margin-bottom: 1.5rem;
}
/* Auth Buttons */
.auth-buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-button {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.875rem 1.5rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s ease;
text-decoration: none;
color: white;
}
.auth-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.auth-button:active {
transform: translateY(0);
}
.auth-button img {
width: 20px;
height: 20px;
}
.auth-button.google {
background: #4285f4;
}
.auth-button.google:hover {
background: #357ae8;
}
.auth-button.discord {
background: #5865f2;
}
.auth-button.discord:hover {
background: #4752c4;
}
.auth-button.twitch {
background: #9146ff;
}
.auth-button.twitch:hover {
background: #772ce8;
}
/* Skip Auth Button */
.skip-auth {
margin-top: 1rem;
padding: 0.75rem;
background: transparent;
border: 1px solid var(--border-color, #444);
color: var(--text-color-secondary, #aaa);
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
width: 100%;
font-size: 0.9rem;
}
.skip-auth:hover {
border-color: var(--text-color-secondary, #aaa);
color: var(--text-color, #fff);
}
/* User Info Display */
.user-info-display {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
margin-bottom: 1rem;
}
.user-info-display img {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid var(--primary-color, #4285f4);
}
.user-info-display .user-details {
flex: 1;
}
.user-info-display .user-name {
font-weight: 600;
color: var(--text-color, #fff);
font-size: 0.9rem;
}
.user-info-display .user-handle {
color: var(--text-color-secondary, #aaa);
font-size: 0.8rem;
}
/* Auth Badge in Control Boxes */
.user-auth-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: rgba(0, 0, 0, 0.3);
border-radius: 20px;
font-size: 0.8rem;
margin-left: auto;
}
.user-auth-badge img {
width: 20px;
height: 20px;
border-radius: 50%;
}
.user-auth-badge .user-handle {
font-weight: 600;
color: var(--primary-color, #4285f4);
}
.user-auth-badge .user-provider {
font-size: 0.7rem;
opacity: 0.7;
text-transform: capitalize;
}
.user-auth-badge .user-provider.google {
color: #4285f4;
}
.user-auth-badge .user-provider.discord {
color: #5865f2;
}
.user-auth-badge .user-provider.twitch {
color: #9146ff;
}
/* Access Denied Modal */
.access-denied-modal {
text-align: center;
}
.access-denied-modal h3 {
color: #ff4444;
margin-bottom: 1rem;
}
.access-denied-modal p {
margin-bottom: 1.5rem;
}
.access-denied-modal button {
padding: 0.75rem 2rem;
background: var(--primary-color, #4285f4);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
.access-denied-modal button:hover {
background: var(--primary-color-dark, #357ae8);
}
/* Room Settings Panel */
.room-settings-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--main-bg, #1a1a1a);
padding: 2rem;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
z-index: 10001;
}
.room-settings-panel h3 {
margin: 0 0 1.5rem 0;
color: var(--text-color, #fff);
}
.setting-group {
margin-bottom: 1.5rem;
}
.setting-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-color, #fff);
font-weight: 500;
}
.setting-group select,
.setting-group input[type="text"] {
width: 100%;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--border-color, #444);
border-radius: 4px;
color: var(--text-color, #fff);
}
.setting-group input[type="checkbox"] {
margin-right: 0.5rem;
}
/* Access Requests */
.access-requests {
background: rgba(255, 165, 0, 0.1);
border: 1px solid rgba(255, 165, 0, 0.3);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
.access-requests h4 {
margin: 0 0 1rem 0;
color: #ffa500;
}
.access-request {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.access-request img {
width: 40px;
height: 40px;
border-radius: 50%;
}
.access-request span {
flex: 1;
color: var(--text-color, #fff);
}
.access-request button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.access-request button:first-of-type {
background: #4caf50;
color: white;
margin-right: 0.5rem;
}
.access-request button:first-of-type:hover {
background: #45a049;
}
.access-request button:last-of-type {
background: #f44336;
color: white;
}
.access-request button:last-of-type:hover {
background: #da190b;
}

188
base64.html Normal file
View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VDO.Ninja CSS to Base64 Converter</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #1E1E1E;
color: #E0E0E0;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h1, h2 {
color: #E0E0E0;
}
textarea {
width: 100%;
height: 200px;
margin-bottom: 10px;
background-color: #2D2D2D;
color: #E0E0E0;
border: 1px solid #3D3D3D;
border-radius: 4px;
padding: 10px;
font-family: monospace;
}
#output {
height: 100px;
}
button {
padding: 10px 20px;
background-color: #4ecca3;
color: #1E1E1E;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.3s;
}
button:hover {
background-color: #45b392;
}
.container {
background-color: #2D2D2D;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
</style>
</head>
<body>
<div class="container">
<h1>VDO.Ninja CSS Base64 Converter</h1>
<div style="margin-bottom: 15px;">
<label>
<input type="radio" name="mode" value="encode" checked onchange="switchMode()"> Encode CSS to Base64
</label>
<label style="margin-left: 20px;">
<input type="radio" name="mode" value="decode" onchange="switchMode()"> Decode Base64 to CSS
</label>
</div>
<textarea id="cssInput" placeholder="Enter your CSS here..."></textarea>
<button onclick="processInput()">Convert</button>
<button onclick="prettifyCSS()" style="margin-left: 10px;">Prettify</button><br><br>
<i>💡Tip: Adding <b>!important</b> after your CSS values can help override existing values.</i>
<h2>Output:</h2>
<textarea id="output" readonly></textarea>
<button onclick="copyToClipboard()">Copy to Clipboard</button>
</div>
<script>
function getMode() {
return document.querySelector('input[name="mode"]:checked').value;
}
function switchMode() {
const mode = getMode();
const input = document.getElementById('cssInput');
const output = document.getElementById('output');
if (mode === 'encode') {
input.placeholder = 'Enter your CSS here...';
} else {
input.placeholder = 'Enter base64 string (with or without &cssb64= prefix)...';
}
// Clear output but preserve input
output.value = '';
}
function processInput() {
const mode = getMode();
if (mode === 'encode') {
convertToBase64();
} else {
decodeFromBase64();
}
}
function convertToBase64() {
const cssInput = document.getElementById('cssInput').value;
// Remove tabs and extra spaces
const sanitizedCSS = cssInput.replace(/\s+/g, ' ').trim();
const base64CSS = btoa(encodeURIComponent(sanitizedCSS));
document.getElementById('output').value = '&cssb64=' + base64CSS;
}
function decodeFromBase64() {
let base64Input = document.getElementById('cssInput').value.trim();
// Remove &cssb64= prefix if present
if (base64Input.startsWith('&cssb64=')) {
base64Input = base64Input.substring(8);
} else if (base64Input.startsWith('cssb64=')) {
base64Input = base64Input.substring(7);
}
try {
const decodedCSS = decodeURIComponent(atob(base64Input));
document.getElementById('output').value = decodedCSS;
} catch (error) {
alert('Invalid base64 string. Please check your input.');
document.getElementById('output').value = '';
}
}
function prettifyCSS() {
const outputField = document.getElementById('output');
let css = outputField.value;
if (!css || css.startsWith('&cssb64=')) {
// If output is base64, decode it first
if (css.startsWith('&cssb64=')) {
try {
css = decodeURIComponent(atob(css.substring(8)));
} catch (error) {
alert('Cannot prettify base64 output. Decode it first.');
return;
}
} else {
alert('No CSS to prettify.');
return;
}
}
// Basic CSS prettification
css = css
.replace(/\s*{\s*/g, ' {\n ') // Add newline after opening brace
.replace(/;\s*/g, ';\n ') // Add newline after semicolon
.replace(/\s*}\s*/g, '\n}\n') // Add newlines around closing brace
.replace(/,\s*/g, ',\n') // Add newline after comma in selectors
.replace(/\n\s*\n/g, '\n') // Remove extra blank lines
.replace(/\s*:\s*/g, ': ') // Consistent spacing around colons
.trim();
// Fix indentation for nested rules
const lines = css.split('\n');
let indentLevel = 0;
const prettifiedLines = lines.map(line => {
line = line.trim();
if (line.endsWith('{')) {
const result = ' '.repeat(indentLevel) + line;
indentLevel++;
return result;
} else if (line === '}') {
indentLevel = Math.max(0, indentLevel - 1);
return ' '.repeat(indentLevel) + line;
} else if (line) {
return ' '.repeat(indentLevel) + line;
}
return line;
});
outputField.value = prettifiedLines.join('\n');
}
function copyToClipboard() {
const output = document.getElementById('output');
output.select();
document.execCommand('copy');
alert('Copied to clipboard!');
}
</script>
</body>
</html>

550
browser.html Normal file
View File

@@ -0,0 +1,550 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Information Detector</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
}
.header h1 {
font-size: 2rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.info-card {
background: white;
border-radius: 15px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
transition: transform 0.3s ease;
}
.info-card:hover {
transform: translateY(-2px);
box-shadow: 0 15px 40px rgba(0,0,0,0.25);
}
.info-section {
margin-bottom: 25px;
padding-bottom: 25px;
border-bottom: 1px solid #eee;
}
.info-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.section-title {
font-size: 1.3rem;
color: #667eea;
margin-bottom: 15px;
font-weight: 600;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f5f5f5;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: 500;
color: #666;
}
.info-value {
font-weight: 600;
color: #333;
text-align: right;
max-width: 60%;
word-break: break-word;
}
.info-value.true {
color: #4caf50;
}
.info-value.false {
color: #f44336;
}
.info-value.number {
color: #2196f3;
}
.performance-indicator {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
.performance-0 {
background: #4caf50;
color: white;
}
.performance-1 {
background: #ff9800;
color: white;
}
.performance-2 {
background: #f44336;
color: white;
}
.safari-highlight {
background: #fffacd;
padding: 15px;
border-radius: 10px;
border: 2px solid #ffd700;
}
@media (max-width: 600px) {
.header h1 {
font-size: 1.5rem;
}
.info-item {
flex-direction: column;
align-items: flex-start;
}
.info-value {
max-width: 100%;
text-align: left;
margin-top: 5px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Device Information Detector</h1>
<p>Comprehensive browser and device capability detection</p>
</div>
<div class="info-card">
<div class="info-section safari-highlight">
<h2 class="section-title">🦁 Safari/WebKit Version</h2>
<div class="info-item">
<span class="info-label">Safari Version</span>
<span class="info-value number" id="safari-version">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">iOS Version</span>
<span class="info-value number" id="ios-version">Detecting...</span>
</div>
</div>
<div class="info-section">
<h2 class="section-title">🌐 Browser Detection</h2>
<div class="info-item">
<span class="info-label">Firefox</span>
<span class="info-value" id="firefox">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">Chromium Version</span>
<span class="info-value" id="chromium">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">Opera GX</span>
<span class="info-value" id="opera-gx">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">Vingester</span>
<span class="info-value" id="vingester">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">MELD</span>
<span class="info-value" id="meld">Detecting...</span>
</div>
</div>
<div class="info-section">
<h2 class="section-title">📱 Device Detection</h2>
<div class="info-item">
<span class="info-label">iOS</span>
<span class="info-value" id="ios">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">iPad</span>
<span class="info-value" id="ipad">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">macOS</span>
<span class="info-value" id="macos">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">Intel Mac</span>
<span class="info-value" id="intel-mac">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">Android</span>
<span class="info-value" id="android">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">Samsung A Series</span>
<span class="info-value" id="samsung-a">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">iPhone 12+</span>
<span class="info-value" id="iphone12">Detecting...</span>
</div>
</div>
<div class="info-section">
<h2 class="section-title">⚡ Hardware Capabilities</h2>
<div class="info-item">
<span class="info-label">CPU Threads</span>
<span class="info-value" id="cpu">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">GPU</span>
<span class="info-value" id="gpu">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">Wake Lock Support</span>
<span class="info-value" id="wake-lock">Detecting...</span>
</div>
<div class="info-item">
<span class="info-label">Performance Score</span>
<span class="info-value" id="performance">Detecting...</span>
</div>
</div>
<div class="info-section">
<h2 class="section-title">📊 Raw Data</h2>
<div class="info-item">
<span class="info-label">User Agent</span>
<span class="info-value" id="user-agent" style="font-size: 0.8rem;">Detecting...</span>
</div>
</div>
</div>
</div>
<script>
// Stub for missing functions/objects
function log(msg) { console.log(msg); }
function errorlog(e) { console.error(e); }
var session = {
quality_wb: 0,
quality_room: 0,
mobile: false,
audioCtx: null
};
// Your original functions (preserved exactly)
function detectCPUSupport() {
let cpuThreads = navigator.hardwareConcurrency;
if (cpuThreads) {
return cpuThreads + " threads";
}
return false;
}
function detectGPUSupport() {
try {
const gl = document.createElement("canvas").getContext("webgl");
if (!gl) {
return false;
}
if (!Firefox) {
try {
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info"); // chrome
if (debugInfo) {
return gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
}
} catch (e) {}
}
try {
return gl.getParameter(gl.RENDERER) || false; // firefox
} catch (e) {}
} catch (e) {}
return false;
}
function isOperaGX() {
return (!!window.opr && !!opr.addons) || !!window.opera || navigator.userAgent.indexOf(" OPR/75") >= 0;
}
function isSamsungASeries() {
return navigator.userAgent.includes("; SM-A") || false;
}
function getChromiumVersion() {
var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
return raw ? parseInt(raw[2], 10) : false;
}
function getiOSVersion() {
try {
var agent = navigator.userAgent;
var start = agent.indexOf("OS ");
if ((agent.indexOf("iPhone") > -1 || agent.indexOf("iPad") > -1) && start > -1) {
return window.Number(agent.substr(start + 3, 3).replace("_", "."));
}
return 0;
} catch (e) {
return 0;
}
return 0;
}
function safariVersion() {
var ver = 0;
try {
ver = navigator.appVersion.split("Version/");
if (ver.length > 1) {
ver = ver[1].split(" Safari");
}
if (ver.length > 1) {
ver = ver[0].split(".");
}
if (ver.length > 1) {
ver = parseInt(ver[0]);
} else {
ver = 0;
}
} catch (e) {
return 0;
}
return ver;
}
function isIntelMac() {
// Check if it's a Mac but not Apple Silicon
if (macOS && navigator.userAgent.indexOf("Intel") >= 0) {
return true;
}
return false;
}
function judgePerformance(){
try {
if (SafariVersion && SafariVersion >= 17 && (iOS || iPad)) { // iphone xr or newer
return 0;
}
const cores = typeof navigator.hardwareConcurrency === 'number' ? navigator.hardwareConcurrency : 0;
if (isIntelMac()) {
if (cores < 6) { // yes. they are that bad.
return 2;
} else {
return 1;
}
}
if (session.mobile && (cores>=4)){ // assume hardware encoded acceleration
return 0;
}
if (!cores){
return 1;
} else if (cores < 4 ){
return 2;
} else if (cores>8){
return 0;
}
return 1
} catch (e) {
return 1; // 99% safe default
}
}
const needsLegacyWakeLock = () => {
try {
if ('wakeLock' in navigator) {
if (Firefox) { // using your existing Firefox detection
return true;
}
if ((iOS || iPad) && SafariVersion < 16.4) { // using your existing iOS/iPad/SafariVersion detection
return true;
}
return false;
}
} catch(e){}
return true; // No Wake Lock API, need legacy keep alive for mobile
};
// Your original detection code
try {
var iOS = !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform); // used by main.js also
var iPad = navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform);
var macOS = navigator.userAgent.indexOf("Mac OS X") != -1;
macOS = macOS && !(iOS || iPad);
var Firefox = navigator.userAgent.indexOf("Firefox") >= 0;
if (Firefox) {
Firefox = parseInt(navigator.userAgent.split("irefox/").pop()) || true;
}
var Android = navigator.userAgent.toLowerCase().indexOf("android") > -1; //&& ua.indexOf("mobile");
var ChromiumVersion = getChromiumVersion();
var OperaGx = isOperaGX();
var SafariVersion = safariVersion() || getiOSVersion(); // I should rename this to webkit
if (iOS || iPad) {
// iOS doesn't yet allow actual browsers, cause it's abusing its duopoly.
if (SafariVersion) {
if (Firefox) {
Firefox = false; // I should rename this to gecko
}
if (ChromiumVersion) {
ChromiumVersion = false; // I should rename this to chromium
}
}
}
var SamsungASeries = isSamsungASeries();
var isVingester = navigator.userAgent.indexOf("Vingester") >= 0;
var gpgpuSupport = detectGPUSupport(); // graphics ; not supported on ios
log(gpgpuSupport);
var cpuSupport = detectCPUSupport(); // thread count ; supported on ios
log(cpuSupport);
var iPhone12Up = false;
var isMELD = false;
if (typeof navigator!== 'undefined' && navigator.userAgent && navigator.userAgent.includes("Meld/")) {
isMELD = true;
}
if (iOS && !iPad) {
if (window.devicePixelRatio.toFixed(2) >= 3 && window.screen.height > 800 && window.screen.width != 414) {
// for reference, https://www.ios-resolution.com/
iPhone12Up = true; // iPhone SE is left out.
}
}
// Detect mobile
session.mobile = iOS || iPad || Android;
session.quality_wb = judgePerformance(); // try to estimate what resolution to use for encoding when not in a room.
if (session.quality_room < session.quality_wb){
session.quality_room = session.quality_wb;
}
} catch (e) {
errorlog(e);
}
// Display results
function updateDisplay() {
// Safari/iOS versions
document.getElementById('safari-version').textContent = safariVersion() || 'Not Safari';
document.getElementById('safari-version').className = 'info-value ' + (safariVersion() ? 'number' : 'false');
document.getElementById('ios-version').textContent = getiOSVersion() || 'Not iOS';
document.getElementById('ios-version').className = 'info-value ' + (getiOSVersion() ? 'number' : 'false');
// Browser detection
document.getElementById('firefox').textContent = Firefox || 'No';
document.getElementById('firefox').className = 'info-value ' + (Firefox ? 'true' : 'false');
document.getElementById('chromium').textContent = ChromiumVersion || 'No';
document.getElementById('chromium').className = 'info-value ' + (ChromiumVersion ? 'number' : 'false');
document.getElementById('opera-gx').textContent = OperaGx ? 'Yes' : 'No';
document.getElementById('opera-gx').className = 'info-value ' + (OperaGx ? 'true' : 'false');
document.getElementById('vingester').textContent = isVingester ? 'Yes' : 'No';
document.getElementById('vingester').className = 'info-value ' + (isVingester ? 'true' : 'false');
document.getElementById('meld').textContent = isMELD ? 'Yes' : 'No';
document.getElementById('meld').className = 'info-value ' + (isMELD ? 'true' : 'false');
// Device detection
document.getElementById('ios').textContent = iOS ? 'Yes' : 'No';
document.getElementById('ios').className = 'info-value ' + (iOS ? 'true' : 'false');
document.getElementById('ipad').textContent = iPad ? 'Yes' : 'No';
document.getElementById('ipad').className = 'info-value ' + (iPad ? 'true' : 'false');
document.getElementById('macos').textContent = macOS ? 'Yes' : 'No';
document.getElementById('macos').className = 'info-value ' + (macOS ? 'true' : 'false');
document.getElementById('intel-mac').textContent = isIntelMac() ? 'Yes' : 'No';
document.getElementById('intel-mac').className = 'info-value ' + (isIntelMac() ? 'true' : 'false');
document.getElementById('android').textContent = Android ? 'Yes' : 'No';
document.getElementById('android').className = 'info-value ' + (Android ? 'true' : 'false');
document.getElementById('samsung-a').textContent = SamsungASeries ? 'Yes' : 'No';
document.getElementById('samsung-a').className = 'info-value ' + (SamsungASeries ? 'true' : 'false');
document.getElementById('iphone12').textContent = iPhone12Up ? 'Yes' : 'No';
document.getElementById('iphone12').className = 'info-value ' + (iPhone12Up ? 'true' : 'false');
// Hardware
document.getElementById('cpu').textContent = cpuSupport || 'Not detected';
document.getElementById('cpu').className = 'info-value ' + (cpuSupport ? 'number' : 'false');
document.getElementById('gpu').textContent = gpgpuSupport || 'Not detected';
document.getElementById('gpu').className = 'info-value ' + (gpgpuSupport ? 'true' : 'false');
document.getElementById('wake-lock').textContent = needsLegacyWakeLock() ? 'Legacy needed' : 'Modern API';
document.getElementById('wake-lock').className = 'info-value ' + (needsLegacyWakeLock() ? 'false' : 'true');
// Performance
const perfScore = session.quality_wb;
const perfText = perfScore === 0 ? 'High' : perfScore === 1 ? 'Medium' : 'Low';
document.getElementById('performance').innerHTML = `<span class="performance-indicator performance-${perfScore}">${perfText} (${perfScore})</span>`;
// User agent
document.getElementById('user-agent').textContent = navigator.userAgent;
}
// Run detection when page loads
updateDisplay();
</script>
</body>
</html>

578
browsercheck.html Normal file
View File

@@ -0,0 +1,578 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<!-- Primary Meta Tags -->
<title>BrowserCheck - Advanced WebRTC and Privacy Analyzer for VDO.Ninja</title>
<meta name="title" content="BrowserCheck - Advanced WebRTC and Privacy Analyzer for VDO.Ninja">
<meta name="description" content="Debug browser capabilities, privacy features, and modern JavaScript support. Comprehensive browser diagnostic tool.">
<!-- Keep existing meta tags -->
<meta name="robots" content="index, follow">
<meta property="og:type" content="website">
<meta name="theme-color" content="#1e1e2e">
<link rel="icon" href="/media/favicon.ico">
<!-- Theme -->
<meta name="theme-color" content="#1e1e2e">
<!-- Favicons -->
<link rel="icon" href="/media/favicon.ico">
<style>
:root {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;
--accent-color: #212121;
--highlight: #3498db;
--success: #2ecc71;
--error: #e74c3c;
--warning: #f39c12;
}
body {
background: var(--bg-color);
color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
margin: 0;
min-height: 100vh;
display: flex;
padding: 20px;
}
.container {
background: var(--accent-color);
border-radius: 8px;
padding: 1.5rem;
margin: auto;
width: 95%;
max-width: 1200px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
grid-auto-flow: dense;
}
h1 {
margin: 0;
font-size: clamp(1.5rem, 3vw, 2rem);
color: var(--highlight);
grid-column: 1 / -1;
}
.info-box {
background: var(--bg-color);
border-radius: 6px;
padding: 1rem;
word-wrap: break-word;
height: fit-content;
min-height: 100px;
display: flex;
flex-direction: column;
}
.label {
color: var(--highlight);
font-size: 0.9rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.value {
font-family: monospace;
font-size: clamp(0.8rem, 1.5vw, 0.9rem);
line-height: 1.4;
}
.status {
display: inline-block;
padding: 0.2em 0.5em;
border-radius: 3px;
margin-left: 0.5em;
font-size: 0.8em;
}
.status-true {
background: var(--success);
color: var(--bg-color);
}
.status-false {
background: var(--error);
color: var(--text-color);
}
#privacy .status-false {
background: var(--success);
color: var(--bg-color);
}
@media (max-width: 640px) {
.container {
grid-template-columns: 1fr;
}
}
.feature-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
}
.feature-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0;
}
.warning {
color: var(--warning);
}
.copy-button {
grid-column: 1 / -1;
background: var(--highlight);
color: var(--bg-color);
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
font-size: 1rem;
transition: opacity 0.2s;
}
.copy-button:hover {
opacity: 0.9;
}
.copy-button:active {
opacity: 0.7;
}
</style>
</head>
<body>
<div class="container">
<h1>Browser Capabilities</h1>
<button id="copyResults" class="copy-button">
Copy Results
</button>
<div class="info-box">
<div class="label">User Agent</div>
<div id="ua" class="value"></div>
</div>
<div class="info-box">
<div class="label">Browser Details</div>
<div id="details" class="value"></div>
</div>
<div class="info-box">
<div class="label">Privacy Features</div>
<div id="privacy" class="value"></div>
</div>
<div class="info-box">
<div class="label">Modern JavaScript</div>
<div id="modernjs" class="value"></div>
</div>
<div class="info-box">
<div class="label">Media Capabilities</div>
<div id="media" class="value"></div>
</div>
<div class="info-box">
<div class="label">WebRTC Support</div>
<div id="webrtc" class="value"></div>
</div>
<div class="info-box">
<div class="label">Audio Context</div>
<div id="audio" class="value"></div>
</div>
</div>
<script>
function addStatusBadge(text, status) {
return `${text} <span class="status status-${status}">${status}</span>`;
}
function formatResults() {
// Helper to clean and add colored emoji
function addStatusEmoji(text, isPrivacy = false) {
const cleanText = text.replace(/<[^>]*>/g, '').trim();
// For privacy features, false is good (green) and true is bad (red)
if (isPrivacy) {
if (cleanText.includes('true')) {
return cleanText.replace(/true$/, '❌ true');
}
return cleanText.replace(/false$/, '✅ false');
}
// For all other features, true is good (green) and false is bad (red)
if (cleanText.includes('true')) {
return cleanText.replace(/true$/, '✅ true');
}
return cleanText.replace(/false$/, '❌ false');
}
// Helper to format details line
function formatDetailsLine(line) {
const [key, ...values] = line.split(':');
return `- ${key}: ${values.join(':')}`.trim();
}
const sections = {
'User Agent': document.getElementById('ua').textContent.trim(),
'Browser Details': document.getElementById('details')
.innerHTML
.split('<br>')
.map(line => formatDetailsLine(line.trim()))
.join('\n'),
'Privacy Features': document.getElementById('privacy')
.innerHTML
.split('<br>')
.map(line => `- ${addStatusEmoji(line, true)}`)
.join('\n'),
'Modern JavaScript': document.getElementById('modernjs')
.innerHTML
.split('<br>')
.map(line => `- ${addStatusEmoji(line)}`)
.join('\n'),
'Media Capabilities': document.getElementById('media')
.innerHTML
.split('<br>')
.map(line => `- ${addStatusEmoji(line)}`)
.join('\n'),
'WebRTC Support': document.getElementById('webrtc')
.innerHTML
.split('<br>')
.map(line => `- ${addStatusEmoji(line)}`)
.join('\n'),
'Audio Context': document.getElementById('audio')
.innerHTML
.split('<br>')
.map(line => {
line = line.replace(/<[^>]*>/g, '').trim();
if (line.includes('AudioContext')) {
return `- ${addStatusEmoji(line)}`;
}
return `- ${line}`;
})
.filter(line => !line.includes('Resume AudioContext'))
.join('\n')
};
const timestamp = new Date().toLocaleString();
const formatted = Object.entries(sections)
.map(([title, content]) => `### ${title}\n${content.trim()}`)
.join('\n\n');
return `## Browser Capabilities Check Results\n*Generated: ${timestamp}*\n\n${formatted}`;
}
document.getElementById('copyResults').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(formatResults());
const btn = document.getElementById('copyResults');
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy Results', 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
});
function checkMediaSupport() {
const mediaElement = document.getElementById('media');
const results = [];
try {
const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
results.push(addStatusBadge('getUserMedia API', hasGetUserMedia));
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then(() => {
results.push(addStatusBadge('Media Permissions', true));
mediaElement.innerHTML = results.join('<br>');
})
.catch(err => {
results.push(addStatusBadge('Media Permissions', false));
results.push(`Error: ${err.name}`);
mediaElement.innerHTML = results.join('<br>');
});
} catch (err) {
results.push(addStatusBadge('Media API', false));
results.push(`Error: ${err.message}`);
mediaElement.innerHTML = results.join('<br>');
}
}
function checkWebRTC() {
const webrtcElement = document.getElementById('webrtc');
const results = [];
try {
const hasRTCPeerConnection = !!window.RTCPeerConnection;
results.push(addStatusBadge('RTCPeerConnection', hasRTCPeerConnection));
const hasRTCDataChannel = 'createDataChannel' in RTCPeerConnection.prototype;
results.push(addStatusBadge('RTCDataChannel', hasRTCDataChannel));
webrtcElement.innerHTML = results.join('<br>');
} catch (err) {
results.push(addStatusBadge('WebRTC Support', false));
results.push(`Error: ${err.message}`);
webrtcElement.innerHTML = results.join('<br>');
}
}
function checkAudioContext() {
const audioElement = document.getElementById('audio');
const results = [];
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (AudioContext) {
const context = new AudioContext();
results.push(addStatusBadge('AudioContext', true));
results.push(`State: ${context.state}`);
results.push(`Sample Rate: ${context.sampleRate}Hz`);
results.push(`Base Latency: ${context.baseLatency || 'Not available'}`);
if (context.state === 'suspended') {
const button = document.createElement('button');
button.textContent = 'Resume AudioContext';
button.onclick = () => {
context.resume().then(() => {
results[1] = `State: ${context.state}`;
audioElement.innerHTML = results.join('<br>');
});
};
results.push(button.outerHTML);
}
} else {
results.push(addStatusBadge('AudioContext', false));
}
} catch (err) {
results.push(addStatusBadge('AudioContext', false));
results.push(`Error: ${err.message}`);
}
audioElement.innerHTML = results.join('<br>');
}
const ua = navigator.userAgent;
document.getElementById('ua').textContent = ua;
const details = {
platform: navigator.platform,
language: navigator.language,
cookiesEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
vendor: navigator.vendor || 'Not available',
viewport: `${window.innerWidth}x${window.innerHeight}px`,
pixelRatio: window.devicePixelRatio
};
document.getElementById('details').innerHTML = Object.entries(details)
.map(([key, value]) => `${key}: ${value}`)
.join('<br>');
const detectPrivacyFeatures = {
async checkWebRTC() {
if (!window.RTCPeerConnection) {
return { supported: false, blocked: true };
}
try {
const pc = new RTCPeerConnection();
const candidates = [];
pc.createDataChannel('');
await pc.createOffer().then(offer => pc.setLocalDescription(offer));
return new Promise(resolve => {
let timeoutId;
pc.onicecandidate = e => {
if (e.candidate) {
candidates.push(e.candidate.candidate);
} else {
clearTimeout(timeoutId);
pc.close();
resolve({
supported: true,
blocked: !candidates.some(c => c.includes('srflx') || c.includes('host')),
candidates
});
}
};
timeoutId = setTimeout(() => {
pc.close();
resolve({ supported: true, blocked: true });
}, 1000);
});
} catch (e) {
return { supported: true, blocked: true, error: e.message };
}
},
checkDNS() {
return new Promise(resolve => {
const img = new Image();
const start = performance.now();
img.onload = () => {
const time = performance.now() - start;
resolve({ blocked: false, loadTime: time });
};
img.onerror = () => {
resolve({ blocked: true });
};
img.src = 'https://www.google.com/favicon.ico?' + new Date().getTime();
});
},
async checkNetworkFeatures() {
const results = {
webrtc: await this.checkWebRTC(),
dns: await this.checkDNS(),
privateMode: !window.localStorage,
doNotTrack: navigator.doNotTrack === "1" || window.doNotTrack === "1",
proxy: await fetch('https://api.ipify.org?format=json')
.then(r => r.json())
.then(() => false)
.catch(() => true)
};
return results;
}
};
const detectModernJS = {
testFeatures() {
return {
optionalChaining: (() => {
try {
eval('const obj = {}; obj?.prop');
return true;
} catch { return false; }
})(),
nullishCoalescing: (() => {
try {
eval('const val = null ?? "default"');
return true;
} catch { return false; }
})(),
logicalAssignment: (() => {
try {
eval('let x = 0; x ||= 1');
return true;
} catch { return false; }
})(),
privateFields: (() => {
try {
eval('class Test { #private = 123; }');
return true;
} catch { return false; }
})(),
arrayAt: (() => {
try {
return typeof [].at === 'function';
} catch { return false; }
})(),
dynamicImport: (() => {
try {
new Function('return import("data:text/javascript;base64,")');
return true;
} catch { return false; }
})(),
bigInt: (() => {
try {
return typeof BigInt === 'function';
} catch { return false; }
})(),
matchAll: (() => {
try {
return typeof ''.matchAll === 'function';
} catch { return false; }
})()
};
}
};
// Initialize all checks
async function initializeChecks() {
// Run existing checks
checkMediaSupport();
checkWebRTC();
checkAudioContext();
// Add user agent and details
const ua = navigator.userAgent;
document.getElementById('ua').textContent = ua;
const details = {
platform: navigator.platform,
language: navigator.language,
cookiesEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
vendor: navigator.vendor || 'Not available',
viewport: `${window.innerWidth}x${window.innerHeight}px`,
pixelRatio: window.devicePixelRatio
};
document.getElementById('details').innerHTML = Object.entries(details)
.map(([key, value]) => `${key}: ${value}`)
.join('<br>');
// Run new privacy checks
const privacyResults = await detectPrivacyFeatures.checkNetworkFeatures();
const privacyElement = document.getElementById('privacy');
const privacyHTML = [
addStatusBadge('WebRTC Leak Protection', privacyResults.webrtc.blocked),
addStatusBadge('DNS Requests Blocked', privacyResults.dns.blocked),
addStatusBadge('Private Mode', privacyResults.privateMode),
addStatusBadge('Proxy Detected', privacyResults.proxy)
];
privacyElement.innerHTML = privacyHTML.join('<br>');
// Run JavaScript feature checks
const jsFeatures = detectModernJS.testFeatures();
const jsElement = document.getElementById('modernjs');
const jsHTML = Object.entries(jsFeatures)
.map(([feature, supported]) => addStatusBadge(feature, supported))
.join('<br>');
jsElement.innerHTML = jsHTML;
}
// Start all checks
initializeChecks();
</script>
</body>
</html>

349
changepassword.html Normal file
View File

@@ -0,0 +1,349 @@
<!DOCTYPE html>
<html>
<head>
<title>VDO.Ninja Hash Generator</title>
<meta charset="utf8" />
<style>
:root {
--primary: #00ff00;
--primary-dark: #00cc00;
--bg-dark: #141926;
--bg-darker: #0d1117;
--bg-card: #1e2738;
--bg-input: #2a364d;
--border: #3a465d;
--text: #ffffff;
--text-muted: #8b949e;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg-dark);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
width: 100%;
}
.header {
text-align: center;
margin-bottom: 2rem;
padding: 2rem 0;
background: var(--bg-darker);
border-bottom: 1px solid var(--border);
}
h1 {
color: var(--primary);
margin-bottom: 1rem;
font-size: 2.5rem;
}
.subtitle {
color: var(--text-muted);
font-size: 1.1rem;
}
.card {
background: var(--bg-card);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
border: 1px solid var(--border);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.input-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-muted);
}
.input-wrapper {
position: relative;
display: flex;
gap: 0.5rem;
}
input {
flex: 1;
padding: 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
font-size: 1rem;
transition: all 0.2s ease;
}
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.1);
}
button {
background: var(--primary);
color: var(--bg-dark);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 1rem;
transition: all 0.2s ease;
white-space: nowrap;
}
button:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
.result {
display: none;
animation: fadeIn 0.3s ease;
}
.result.visible {
display: block;
}
.hash-display {
display: flex;
align-items: center;
gap: 1rem;
background: var(--bg-input);
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.hash-value {
font-family: monospace;
font-size: 1.2rem;
color: var(--primary);
}
.url-preview {
font-family: monospace;
background: var(--bg-input);
padding: 1rem;
border-radius: 4px;
word-break: break-all;
}
.info {
color: var(--text-muted);
}
.info h3 {
color: var(--text);
margin-bottom: 1rem;
}
.info p {
margin-bottom: 1rem;
}
code {
background: var(--bg-input);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: monospace;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.toggle-password {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.5rem;
}
.copy-notification {
position: fixed;
top: 1rem;
right: 1rem;
background: var(--primary);
color: var(--bg-dark);
padding: 0.75rem 1.5rem;
border-radius: 4px;
display: none;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (max-width: 600px) {
.container {
padding: 1rem;
}
h1 {
font-size: 2rem;
}
.input-wrapper {
flex-direction: column;
}
button {
width: 100%;
}
}
</style>
</head>
<body>
<div class="header">
<h1>VDO.Ninja Hash Generator</h1>
<p class="subtitle">Generate secure hash values for your VDO.Ninja rooms and streams</p>
</div>
<div class="container">
<div class="card">
<div class="input-group">
<label for="passwordInput">VDO.Ninja Password</label>
<div class="input-wrapper">
<input type="password" id="passwordInput" placeholder="Enter your VDO.Ninja password" autocomplete="off">
<button onclick="togglePassword()">Show</button>
<button onclick="generateHashFromInput()">Generate Hash</button>
</div>
</div>
<div class="result" id="resultDiv">
<div class="hash-display">
<span>Hash:</span>
<span class="hash-value" id="hashResult"></span>
<button onclick="copyHash()">Copy Hash</button>
</div>
<div class="url-preview" id="urlPreview"></div>
</div>
</div>
<div class="card info">
<h3>About Hash Generation</h3>
<p>This tool generates a hash value that can be used with VDO.Ninja's <code>&hash</code> parameter instead of using <code>&password</code>. The hash provides a secure way to verify passwords without exposing the actual password in the URL.</p>
<p>Using a hash instead of a password in your URL:</p>
<p>❌ Instead of: <code>vdo.ninja/?room=yourroom&password=yourpassword</code></p>
<p>✅ Use: <code>vdo.ninja/?room=yourroom&hash=99e5</code></p>
<p>The hash value is derived from your password but doesn't contain the actual password, making it safer to share URLs while maintaining security.</p>
</div>
</div>
<div class="copy-notification" id="copyNotification">Hash copied to clipboard!</div>
<script>
function generateHash(str, length=false) {
var buffer = new TextEncoder("utf-8").encode(str);
return crypto.subtle.digest("SHA-256", buffer).then(
function(hash) {
hash = new Uint8Array(hash);
if (length) {
hash = hash.slice(0, parseInt(parseInt(length)/2));
}
return toHexString(hash);
}
);
}
function toHexString(byteArray) {
return Array.prototype.map.call(byteArray, function(byte) {
return ('0' + (byte & 0xFF).toString(16)).slice(-2);
}).join('');
}
function generateHashFromInput() {
var password = document.getElementById('passwordInput').value;
if (!password) {
alert('Please enter a password');
return;
}
password = password.trim();
password = encodeURIComponent(password);
var salt = location.hostname;
if (location.hostname == "steveseguin.github.io") {
salt = "vdo.ninja";
} else if (["vdo.ninja","rtc.ninja","versus.cam","socialstream.ninja"].includes(location.hostname.split(".").slice(-2).join("."))) {
salt = location.hostname.split(".").slice(-2).join(".");
}
generateHash(password + salt, 4).then(function(hash) {
document.getElementById('hashResult').textContent = hash;
document.getElementById('urlPreview').textContent = `vdo.ninja/?room=yourroom&hash=${hash}`;
document.getElementById('resultDiv').classList.add('visible');
});
}
function copyHash() {
const hash = document.getElementById('hashResult').textContent;
navigator.clipboard.writeText(hash).then(function() {
showNotification();
});
}
function togglePassword() {
const input = document.getElementById('passwordInput');
const button = input.nextElementSibling;
if (input.type === 'password') {
input.type = 'text';
button.textContent = 'Hide';
} else {
input.type = 'password';
button.textContent = 'Show';
}
}
function showNotification() {
const notification = document.getElementById('copyNotification');
notification.style.display = 'block';
setTimeout(() => {
notification.style.display = 'none';
}, 2000);
}
// Enable Enter key to generate hash
document.getElementById('passwordInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
generateHashFromInput();
}
});
</script>
</body>
</html>

1539
check.html Normal file

File diff suppressed because it is too large Load Diff

791
clipboard.html Normal file
View File

@@ -0,0 +1,791 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shared Clipboard - P2P Text Sync | VDO.Ninja</title>
<!-- SEO Meta Tags -->
<meta name="description" content="Share text instantly across devices with VDO.Ninja's P2P Shared Clipboard. No server storage, real-time sync, secure peer-to-peer connection. Perfect for quick text sharing between devices.">
<meta name="keywords" content="shared clipboard, p2p text sync, webrtc clipboard, cross-device clipboard, vdo.ninja, peer to peer, real-time text sharing, secure clipboard">
<meta name="author" content="VDO.Ninja">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://vdo.ninja/clipboard">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://vdo.ninja/clipboard">
<meta property="og:title" content="Shared Clipboard - P2P Text Sync | VDO.Ninja">
<meta property="og:description" content="Share text instantly across devices with peer-to-peer technology. No server storage, real-time sync, completely secure.">
<meta property="og:image" content="https://vdo.ninja/media/vdo-ninja-banner.png">
<meta property="og:site_name" content="VDO.Ninja">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://vdo.ninja/clipboard">
<meta property="twitter:title" content="Shared Clipboard - P2P Text Sync | VDO.Ninja">
<meta property="twitter:description" content="Share text instantly across devices with peer-to-peer technology. No server storage, real-time sync.">
<meta property="twitter:image" content="https://vdo.ninja/media/vdo-ninja-banner.png">
<!-- Additional Meta -->
<meta name="application-name" content="VDO.Ninja Shared Clipboard">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Shared Clipboard">
<!-- Favicon -->
<link rel="icon" type="image/png" href="https://vdo.ninja/favicon.png">
<link rel="apple-touch-icon" href="https://vdo.ninja/apple-touch-icon.png">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "VDO.Ninja Shared Clipboard",
"description": "Real-time P2P text synchronization across devices using WebRTC technology",
"url": "https://vdo.ninja/clipboard",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "Any",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"creator": {
"@type": "Organization",
"name": "VDO.Ninja",
"url": "https://vdo.ninja"
},
"featureList": [
"Peer-to-peer text synchronization",
"No server storage",
"Real-time updates",
"Cross-device compatibility",
"Secure WebRTC connections"
]
}
</script>
<style>
* {
box-sizing: border-box;
}
/* Light mode (default) */
:root {
--bg-primary: #fafafa;
--bg-secondary: #ffffff;
--bg-tertiary: #f5f5f5;
--bg-input: #f8f8f8;
--text-primary: #1a1a1a;
--text-secondary: #4a4a4a;
--text-tertiary: #6a6a6a;
--border-primary: #e0e0e0;
--border-secondary: #d0d0d0;
--accent-primary: #2563eb;
--accent-hover: #1d4ed8;
--success: #059669;
--danger: #dc2626;
--info-bg: #e8f0ff;
--info-text: #1e40af;
--info-border: #b8d4ff;
--shadow: rgba(0,0,0,0.08);
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #242424;
--bg-tertiary: #2a2a2a;
--bg-input: #1e1e1e;
--text-primary: #e8e8e8;
--text-secondary: #b8b8b8;
--text-tertiary: #888888;
--border-primary: #3a3a3a;
--border-secondary: #4a4a4a;
--accent-primary: #3b82f6;
--accent-hover: #2563eb;
--success: #10b981;
--danger: #ef4444;
--info-bg: #1e293b;
--info-text: #94a3b8;
--info-border: #334155;
--shadow: rgba(0,0,0,0.3);
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
.container {
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 4px 20px var(--shadow);
padding: 30px;
border: 1px solid var(--border-primary);
}
h1 {
margin: 0 0 10px 0;
color: var(--text-primary);
font-size: 28px;
font-weight: 600;
}
.subtitle {
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
}
.share-section {
background: var(--bg-tertiary);
border: 2px dashed var(--border-secondary);
border-radius: 8px;
padding: 20px;
margin-bottom: 25px;
}
.share-label {
font-weight: 600;
margin-bottom: 10px;
color: var(--text-secondary);
font-size: 14px;
}
.share-link-container {
display: flex;
gap: 10px;
align-items: stretch;
}
#shareLink {
flex: 1;
padding: 12px 16px;
border: 1px solid var(--border-primary);
border-radius: 6px;
font-size: 14px;
background: var(--bg-input);
color: var(--text-primary);
font-family: monospace;
}
.copy-button {
padding: 12px 20px;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
}
.copy-button:hover {
background: var(--accent-hover);
}
.copy-button:active {
transform: scale(0.98);
}
.copy-button.copied {
background: var(--success);
}
.clipboard-section {
margin-bottom: 20px;
}
.clipboard-label {
font-weight: 600;
margin-bottom: 10px;
color: var(--text-secondary);
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
}
#sharedClipboard {
width: 100%;
min-height: 300px;
padding: 16px;
border: 2px solid var(--border-primary);
border-radius: 8px;
font-size: 15px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
resize: vertical;
background: var(--bg-input);
color: var(--text-primary);
transition: border-color 0.2s, background-color 0.3s ease;
}
#sharedClipboard:focus {
outline: none;
border-color: var(--accent-primary);
background: var(--bg-secondary);
}
#sharedClipboard::placeholder {
color: var(--text-tertiary);
}
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 13px;
margin-bottom: 15px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
transition: background 0.3s;
}
.status-indicator.connected {
background: var(--success);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.status-text {
color: var(--text-tertiary);
}
.peers-count {
color: var(--text-secondary);
font-weight: 500;
}
.char-count {
color: var(--text-tertiary);
font-size: 12px;
}
.info-section {
margin-top: 30px;
padding: 20px;
background: var(--info-bg);
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
border: 1px solid var(--info-border);
}
.info-section h3 {
margin: 0 0 10px 0;
color: var(--info-text);
font-size: 16px;
}
.info-section ul {
margin: 10px 0;
padding-left: 20px;
}
.info-section li {
margin: 5px 0;
color: var(--info-text);
}
.copy-button[style*="--danger"]:hover {
background: #b91c1c;
}
@media (max-width: 600px) {
body {
padding: 10px;
font-size: 16px;
}
.container {
padding: 20px;
}
h1 {
font-size: 24px;
}
.share-link-container {
flex-direction: column;
gap: 12px;
}
#shareLink {
font-size: 16px;
padding: 14px 16px;
}
.copy-button {
width: 100%;
padding: 14px 20px;
font-size: 16px;
}
#sharedClipboard {
font-size: 16px;
min-height: 250px;
}
.info-section {
margin-top: 20px;
}
}
@media (max-width: 400px) {
body {
padding: 8px;
}
.container {
padding: 16px;
}
h1 {
font-size: 20px;
}
.subtitle {
font-size: 13px;
}
}
@media (max-width: 600px) {
/* Update the existing media query section */
div[style*="display: flex"] {
gap: 8px;
}
div[style*="display: flex"] .copy-button {
padding: 14px 10px;
font-size: 15px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>🔗 Shared Clipboard</h1>
<p class="subtitle">Real-time P2P text synchronization across devices</p>
<div class="share-section">
<div class="share-label">Share this link to sync clipboards:</div>
<div class="share-link-container">
<input type="text" id="shareLink" readonly>
<button class="copy-button" onclick="copyShareLink()">Copy Link</button>
</div>
</div>
<div class="clipboard-section">
<div class="clipboard-label">
<span>Shared Clipboard</span>
<span class="char-count" id="charCount">0 characters</span>
</div>
<textarea
id="sharedClipboard"
placeholder="Type or paste text here. It will automatically sync with all connected devices..."
></textarea>
</div>
<div class="status">
<span class="status-indicator" id="statusIndicator"></span>
<span class="status-text" id="statusText">Connecting...</span>
<span class="peers-count" id="peersCount"></span>
</div>
<button class="copy-button" style="width: 100%; margin-top: 15px;" onclick="copyClipboardContent()">
Copy Clipboard Content
</button>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button class="copy-button" style="flex: 1; background: var(--danger);" onclick="clearClipboard()">
Clear
</button>
<button class="copy-button" style="flex: 1; background: var(--danger);" onclick="clearAndPaste()">
Clear & Paste
</button>
</div>
<div class="info-section">
<h3> How it works</h3>
<ul>
<li>Share the link above with other devices or users</li>
<li>Any text typed or pasted will sync automatically</li>
<li>All data is transmitted peer-to-peer (no server storage)</li>
<li>Perfect for quickly sharing text between devices</li>
</ul>
</div>
</div>
<!-- Load the VDO.Ninja SDK -->
<script src="vdoninja-sdk.js"></script>
<script>
let sdk = null;
let roomId = '';
let streamId = '';
let connectedPeers = new Set();
let isUpdatingFromRemote = false;
let syncDebounceTimer = null;
// Initialize on page load
window.addEventListener('load', async () => {
await initializeSharedClipboard();
});
async function initializeSharedClipboard() {
// Get or generate room ID from URL
const urlParams = new URLSearchParams(window.location.search);
roomId = urlParams.get('room');
if (!roomId) {
// Generate a new room ID
roomId = generateRoomId();
// Update URL without reloading
const newUrl = window.location.origin + window.location.pathname + '?room=' + roomId;
window.history.replaceState({}, '', newUrl);
}
// Generate unique stream ID for this instance
streamId = 'clipboard-' + Math.random().toString(36).substr(2, 9);
// Update share link
const shareLink = window.location.origin + window.location.pathname + '?room=' + roomId;
document.getElementById('shareLink').value = shareLink;
// Initialize SDK
sdk = new VDONinjaSDK({
room: roomId,
password: false, // No encryption for simplicity
debug: true
});
// Set up event listeners
setupSDKEventListeners();
// Set up textarea event listener
const textarea = document.getElementById('sharedClipboard');
textarea.addEventListener('input', handleTextareaChange);
// Connect to the network
try {
await sdk.connect();
// Announce ourselves as a data-only peer
await sdk.announce({
streamID: streamId,
room: roomId,
label: 'shared-clipboard'
});
// Join room to discover other peers
await sdk.joinRoom({ room: roomId });
updateStatus('Connected', true);
console.log('Connected to room:', roomId);
} catch (error) {
console.error('Connection error:', error);
updateStatus('Connection failed', false);
}
}
function clearClipboard() {
const textarea = document.getElementById('sharedClipboard');
textarea.value = '';
// Update character count
document.getElementById('charCount').textContent = '0 characters';
// Send update to peers
sendContentUpdate('');
// Visual feedback
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Cleared!';
setTimeout(() => {
button.textContent = originalText;
}, 1500);
}
async function clearAndPaste() {
const textarea = document.getElementById('sharedClipboard');
try {
// Clear first
textarea.value = '';
// Try to read from clipboard
const text = await navigator.clipboard.readText();
textarea.value = text;
// Update character count
document.getElementById('charCount').textContent = text.length + ' characters';
// Send update to peers
sendContentUpdate(text);
// Visual feedback
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Pasted!';
setTimeout(() => {
button.textContent = originalText;
}, 1500);
} catch (err) {
// Fallback if clipboard API fails
console.error('Failed to read clipboard:', err);
// Visual feedback for error
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Paste failed';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}
}
function setupSDKEventListeners() {
// Connection status
sdk.addEventListener('connected', () => {
console.log('Connected to signaling server');
});
sdk.addEventListener('disconnected', () => {
updateStatus('Disconnected', false);
connectedPeers.clear();
updatePeersCount();
});
// Peer events
sdk.addEventListener('peerConnected', (event) => {
const peerId = event.detail.uuid;
connectedPeers.add(peerId);
updatePeersCount();
console.log('Peer connected:', peerId);
// Send current clipboard content to new peer
sendCurrentContent(peerId);
});
sdk.addEventListener('peerDisconnected', (event) => {
const peerId = event.detail.uuid;
connectedPeers.delete(peerId);
updatePeersCount();
console.log('Peer disconnected:', peerId);
});
// Data channel events
sdk.addEventListener('dataChannelOpen', (event) => {
console.log('Data channel opened with:', event.detail.uuid);
});
// Handle incoming data
sdk.addEventListener('dataReceived', (event) => {
handleIncomingData(event.detail);
});
// Handle room listings
sdk.addEventListener('listing', async (event) => {
if (event.detail.list && event.detail.list.length > 0) {
console.log('Found peers in room:', event.detail.list.length);
// Connect to other peers in mesh mode
for (const peer of event.detail.list) {
if (peer.streamID && peer.streamID !== streamId) {
try {
await sdk.quickView({
streamID: peer.streamID,
password: false,
audio: false,
video: false
});
console.log('Connected to peer:', peer.streamID);
} catch (error) {
console.error('Failed to connect to peer:', peer.streamID, error);
}
}
}
}
});
}
function handleTextareaChange() {
if (isUpdatingFromRemote) return;
const textarea = document.getElementById('sharedClipboard');
const content = textarea.value;
// Update character count
document.getElementById('charCount').textContent = content.length + ' characters';
// Debounce sync to avoid too many messages
clearTimeout(syncDebounceTimer);
syncDebounceTimer = setTimeout(() => {
sendContentUpdate(content);
}, 100); // 100ms debounce
}
function sendContentUpdate(content) {
if (!sdk || connectedPeers.size === 0) return;
const message = {
type: 'clipboard-update',
content: content,
timestamp: Date.now(),
sender: streamId
};
// Send to all connected peers
sdk.sendData(message);
console.log('Sent content update to peers');
}
function sendCurrentContent(peerId) {
if (!sdk) return;
const textarea = document.getElementById('sharedClipboard');
const message = {
type: 'clipboard-sync',
content: textarea.value,
timestamp: Date.now(),
sender: streamId
};
// Send to specific peer
sdk.sendData(message, peerId);
console.log('Sent current content to peer:', peerId);
}
function handleIncomingData(detail) {
const { data, uuid } = detail;
if (!data || typeof data !== 'object') return;
if (data.type === 'clipboard-update' || data.type === 'clipboard-sync') {
// Update textarea with received content
isUpdatingFromRemote = true;
const textarea = document.getElementById('sharedClipboard');
textarea.value = data.content || '';
// Update character count
document.getElementById('charCount').textContent = textarea.value.length + ' characters';
// Reset flag after a short delay
setTimeout(() => {
isUpdatingFromRemote = false;
}, 50);
console.log('Received content update from:', data.sender);
}
}
function generateRoomId() {
// Generate a readable room ID
const adjectives = ['quick', 'bright', 'swift', 'smart', 'cool', 'neat', 'fast', 'sharp'];
const nouns = ['fox', 'hawk', 'wolf', 'bear', 'lion', 'eagle', 'shark', 'tiger'];
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const num = Math.floor(Math.random() * 1000);
return `${adj}-${noun}-${num}`;
}
function updateStatus(text, isConnected) {
const indicator = document.getElementById('statusIndicator');
const statusText = document.getElementById('statusText');
statusText.textContent = text;
if (isConnected) {
indicator.classList.add('connected');
} else {
indicator.classList.remove('connected');
}
}
function updatePeersCount() {
const peersCount = document.getElementById('peersCount');
const count = connectedPeers.size;
if (count === 0) {
peersCount.textContent = '';
} else if (count === 1) {
peersCount.textContent = '• 1 device connected';
} else {
peersCount.textContent = `${count} devices connected`;
}
}
function copyShareLink() {
const shareLink = document.getElementById('shareLink');
shareLink.select();
document.execCommand('copy');
// Update button text temporarily
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Copied!';
button.classList.add('copied');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('copied');
}, 2000);
}
function copyClipboardContent() {
const textarea = document.getElementById('sharedClipboard');
textarea.select();
document.execCommand('copy');
// Update button text temporarily
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Copied!';
button.classList.add('copied');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('copied');
}, 2000);
}
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (sdk) {
sdk.disconnect();
}
});
</script>
</body>
</html>

147
cloud.html Normal file
View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VDO.Ninja Cloud Sync Setup</title>
<style>
:root {
color-scheme: dark;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #04070f;
color: #f4f8ff;
}
body {
margin: 0;
padding: 0 16px 32px;
line-height: 1.5;
}
header {
padding: 32px 0 8px;
}
h1 {
margin: 0 0 8px;
font-size: clamp(1.8rem, 2.4vw, 2.6rem);
}
h2 {
margin-top: 32px;
font-size: 1.3rem;
}
h3 {
margin-top: 18px;
font-size: 1rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
p {
max-width: 720px;
}
a {
color: #8fc6ff;
}
ol {
max-width: 760px;
padding-left: 20px;
}
code {
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
padding: 2px 4px;
font-size: 0.9rem;
}
.card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 18px;
margin-top: 18px;
max-width: 820px;
}
.note {
font-size: 0.9rem;
opacity: 0.75;
}
@media (max-width: 640px) {
body {
padding: 0 12px 24px;
}
.card {
padding: 16px;
}
}
</style>
</head>
<body>
<header>
<p>VDO.Ninja · Cloud Sync Guide</p>
<h1>Configure Google Drive & Dropbox uploads</h1>
<p>
The podcast studio can stream each local recording chunk to cloud storage for redundancy. Use the steps below
to authorize the built-in Google Drive integration or to generate a Dropbox personal access token that the
studio can store locally.
</p>
</header>
<section id="google-drive" class="card">
<h2>Google Drive (built-in OAuth)</h2>
<ol>
<li>Open the podcast studio (`?studio=podcast`) and locate the <strong>Cloud Sync</strong> card.</li>
<li>Click <em>Link Google Drive</em>. Google Identity Services opens a popup window.</li>
<li>Pick the Google account that will own the uploads and approve the <code>drive.file</code> scope.</li>
<li>
Once the popup closes, the status pill switches to “Linked” and future recordings stream into your Drive root
(or the custom folder configured via <code>&amp;gdrivefolder=YourFolder</code>).
</li>
</ol>
<p class="note">
The Drive token stays in the browser session. Re-click the button whenever the token expires or if you switch
accounts.
</p>
</section>
<section id="dropbox" class="card">
<h2>Dropbox (OAuth + refresh tokens)</h2>
<p>
The Dropbox integration now mirrors the Google Drive workflow: clicking <em>Link Dropbox</em> opens an OAuth
popup, requests the <code>files.content.write</code> / <code>files.metadata.write</code> scopes, and stores a
refresh token locally so future sessions can renew access automatically. No server-side helpers are required—the
entire exchange happens in your browser.
</p>
<h3>Authorize via OAuth</h3>
<ol>
<li>Open the podcast studio (`?studio=podcast`) and scroll to the <strong>Cloud Sync</strong> card.</li>
<li>Click <em>Link Dropbox</em>. Allow the popup (make sure your browser isnt blocking it).</li>
<li>
Sign in with the Dropbox account that should receive uploads and approve the requested scopes. The popup will
close once the code exchange completes.
</li>
<li>The studio status should switch to “Dropbox linked. Recordings will upload automatically.”</li>
</ol>
<p class="note">
Tokens never leave your browser. We store the refresh token (and the most recent short-lived access token) inside
<code>localStorage</code> so background uploads can reconnect silently even after several hours.
</p>
<h3>Manual fallback token (optional)</h3>
<p>
If you need an emergency override—e.g., when the OAuth popup cannot run inside a kiosk build—you can still paste
a personal access token into the Dropbox field:
</p>
<ol>
<li>
Visit
<a href="https://www.dropbox.com/developers/apps" target="_blank" rel="noopener"
>https://www.dropbox.com/developers/apps</a
>, open your scoped app, and click <em>Generate access token</em>.
</li>
<li>Copy the token, paste it into the Cloud Sync token box, then click <strong>Link Dropbox</strong>.</li>
<li>
You can also launch the studio with <code>?dropbox=YOUR_TOKEN</code>; the textbox will populate automatically.
</li>
</ol>
<p class="note">
Dropboxs generated tokens expire quickly (typically ~4 hours) and do not refresh. Prefer the OAuth link unless
youre temporarily sidestepping browser restrictions.
</p>
</section>
</body>
</html>

264
cloudflare.html Normal file
View File

@@ -0,0 +1,264 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Cloudflare Auth</title>
<style>
body {
font-family: Arial, sans-serif;
font-family: Arial, sans-serif;
background-color: #f5f5f5;
margin: 0;
padding: 0;
background-color: #f0f0f0;
background-image:
linear-gradient(to right, #e0e0e0 1px, transparent 1px),
linear-gradient(to bottom, #e0e0e0 1px, transparent 1px);
background-size: 10px 10px;
backdrop-filter: blur(3px);
}
h1 {
color: #333;
margin-bottom: 20px;
}
form {
background-color: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.1);
width: 300px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
}
input[type="text"],
input[type="password"],
input[type="number"] {
width: 93%;
padding: 8px;
margin-bottom: 15px;
border: 1px solid #ccc;
border-radius: 4px;
}
input[type="number"] {
max-width:80px;
}
button {
background-color: #007bff;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
textarea {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
resize: vertical;
max-width: 500px;
min-height: 100px;
}
.section{
max-width:700px;
padding: 20px;
overflow: auto;
}
.main {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
align-content: space-around;
justify-content: center;
}
.secondary {
padding: 50px;
display: flex;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
align-content: space-around;
justify-content: center;
max-width:1200px;
margin:auto;
}
</style>
</head>
<body>
<div class='main'>
<h1>Generate Cloudflare Auth for VDO.Ninja</h1>
<form id="postForm">
<label for="userId">Cloudflare Account ID:</label>
<input type="text" id="userId" name="userId" autocomplete="userId" required>
<br><br>
<label for="accessToken">Cloudflare Stream Access Token:</label>
<input type="password" id="accessToken" autocomplete="accessToken" name="accessToken" required>
<br><br>
<label for="expiration">Hours until expiration</label>
<input min="0" type="number" id="expiration" name="expiration" placeholder="optional">
<br><br>
<button type="button" id="submitButton">Generate</button>
</form>
<br><br>
<label for="response">Generated URL parameter to add to VDO.Ninja:</label>
<textarea id="response" rows="5" cols="50" readonly placeholder="Use this parameter with your VDO.Ninja links in place of &meshcast"></textarea>
<div class="section">
<h2>
What you can do with Cloudflare + VDO.Ninja?
</h2>
<h3>Meshcast-alternative</h3>
<p>Instead of using Meshcast to broadcast video from director to guest, or guest to scene, you can use Cloudflare instead.</p>
<p>Meshcast, or any compatible WHIP/WHEP service, can help reduce CPU and network load of guests by offloading distribution to a server, compared to using the peer-to-peer default of VDO.Ninja
<p>VDO.Ninja has built-in support for Cloudflare's WHIP/WHEP, so setup and use is relatively easy.</p>
<h3>Isolated guest recording remotely via WHEP</h3>
<p>You can record the streams of each guest via WHEP remotely, without transcoding and without additional load on the guests.</p>
<p>This offers a redundant backup for your recordings, but also makes it easier to do higher quality VODs edits after the live ends.</p>
<p>Raspberry.Ninja offers WHEP recording, via GStreamer for example, but FFMpeg builds may also support direct WHEP recording</p>
<h3>Pricing</h3>
<p>Cloudflare has decent pricing, however it's a bit obsecure at times what the limits actually are.</p>
<h3>MediaMTX</h3>
<p>If you prefer rolling your own SFU service, VDO.Ninja supports MediaMTX. (open-source!)</p>
<p>Just add <b>&mediamtx=yourdomain.com</b> to your VDO.Ninja publishing URLs to have it use your own MediaMTX server.
</div>
</div>
<div class='secondary'>
<h2>
How it works?
</h2>
<p>When used with VDO.Ninja, video is published to Cloudflare via WHIP, and the WHEP playback URL is distributed to viewers. Unless otherwise specified, viewers will use the WHEP URL as the source of media from the publisher, instead of using the normal peer-to-peer mode. This has the effect of reducing the CPU and network load when sharing media with multiple videos, as instead of distributing media via peer-to-peer, the media is distributed via a server. This approach does have some downsides also, so its not normally advisable unless desired or needed.</p>
<h2>
Why do I need a special URL parameter?
</h2>
<p>The reason we need a special generated URL parameter is because Cloudflare requires user accounts, unlike Meshcast. While you can generate WHIP URLs within your Cloudflare dashboard, and use them on VDO.Ninja links using &whipout, you'd need to create one per guest. Instead here, we're using our Cloudflare credentials to automatically create unique WHIP ingest URLs on demand for each guest, so you can get away with one-invite link for all your guests.</p>
<p>Since it's not advisable to share your Cloudflare credentials, particularly with random guests, this page will encrypt your credentials into URL-friendly parameter. Only the VDO.Ninja servers knows the decryption key, which limits what guest can do with the encrypted key. You can delete or restrict the credentials provided to VDO.Ninja from your Cloudflare dashboard, allowing you to limit or revoke any trust provided to VDO.Ninja.</p>
<h2>
Where to get my Cloudflare account ID and token?
</h2>
<p>The Cloudflare account ID can be found on the right-hand side of the Workers & Pages (Overview) page, or it can be found on the right-lower side of any of your Website (domain) overview pages.</p>
<p>
As for the API token, you'll need to create it, with limited permissions.
<ul>
<li>Go to <a href='https://dash.cloudflare.com/profile/api-tokens' target="_blank">https://dash.cloudflare.com/profile/api-tokens</a></li>
<li>Click to Create Token (API Tokens)</li>
<li>Click Get started with the Create Custom Token option</li>
<li>Provide a token name, and for the permissions select Account -> Stream -> <b>Edit</b> (not Read)</li>
<li>You can define a time-to-live (TTL), if you wish for the token to auto-expire.</li>
</ul>
You should now have access to both access token and account ID.
</p>
<h2>
Can I self-host or hard-code my Cloudflare credentials?
</h2>
<p>Yes, the code is open-sourced and it can be self-hosted, however please be aware there is limited support for those self-hosting.</p>
<h2>
Does VDO.Ninja track me or store my private information?
</h2>
<p>Please refer to the <a href='https://docs.vdo.ninja/help/privacy-and-security-details'>privacy policy</a>, although the short answer is no. I can't say the same for Cloudflare, so please refer to their terms of service.
</div>
<script>
function removeStorage(cname){
localStorage.removeItem(cname);
}
function clearStorage(){
localStorage.clear();
if (!session.cleanOutput){
warnUser("The local storage and saved settings have been cleared", 1000);
}
}
function setStorage(cname, cvalue, hours=9999){ // not actually a cookie
var now = new Date();
var item = {
value: cvalue,
expiry: now.getTime() + (hours * 60 * 60 * 1000),
};
try{
localStorage.setItem(cname, JSON.stringify(item));
}catch(e){errorlog(e);}
}
function getStorage(cname) {
try {
var itemStr = localStorage.getItem(cname);
} catch(e){
errorlog(e);
return;
}
if (!itemStr) {
return "";
}
var item = JSON.parse(itemStr);
var now = new Date();
if (now.getTime() > item.expiry) {
localStorage.removeItem(cname);
return "";
}
return item.value;
}
document.getElementById("accessToken").value = getStorage("accessToken") || "";
document.getElementById("userId").value = getStorage("userId") || "";
document.getElementById("expiration").value = getStorage("expiration") || "";
document.getElementById("submitButton").addEventListener("click", async function () {
const accessToken = document.getElementById("accessToken").value;
const userId = document.getElementById("userId").value;
const expiration = document.getElementById("expiration").value;
if (!accessToken || !userId) {
alert("Access Token and User ID are required.");
return;
}
const data = {
accessToken: accessToken,
userId: userId,
expiration: Math.round(expiration*60)
};
try {
const response = await fetch("https://cloudflare.vdo.ninja/encode/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
});
const responseData = await response.text();
console.log(responseData);
setStorage("accessToken",accessToken);
setStorage("userId", userId);
setStorage("expiration", expiration);
document.getElementById("response").value = "&cftoken="+encodeURIComponent(responseData);
} catch (error) {
console.error("Error:", error);
document.getElementById("response").value = "An error occurred.";
}
});
</script>
</body>
</html>

781
codeccomparison.html Normal file
View File

@@ -0,0 +1,781 @@
<!DOCTYPE html>
<html>
<head>
<title>H.264 Encoder Performance Comparison</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.chart-container {
width: 100%;
height: 500px;
}
.controls {
margin: 20px 0;
display: flex;
gap: 20px;
}
select {
padding: 8px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 15px 0;
}
.legend-item {
display: flex;
align-items: center;
margin-right: 15px;
}
.color-box {
width: 15px;
height: 15px;
margin-right: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>H.264 Encoder Performance Comparison</h1>
<div class="controls">
<div>
<label for="metric-select">Metric:</label>
<select id="metric-select">
<option value="bitrate">Bitrate (Mbps)</option>
<option value="quality">Quality (PSNR dB)</option>
<option value="speed">Encoding Speed (FPS)</option>
<option value="efficiency">Compression Efficiency (Quality/Bitrate)</option>
</select>
</div>
<div>
<label for="resolution-select">Resolution:</label>
<select id="resolution-select">
<option value="1080p">1080p</option>
<option value="4k">4K</option>
</select>
</div>
</div>
<div class="legend" id="legend"></div>
<div class="chart-container">
<canvas id="performanceChart"></canvas>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
<script>
// Data correction: FFMPEG should outperform NVIDIA encoders
// Sample data representing H.264 encoder performance with speed presets
const encoderData = {
// FFMPEG x264 presets
"x264-ultrafast": {
color: "#3366CC",
"1080p": {
bitrate: 6.2,
quality: 36.5,
speed: 220,
efficiency: 5.89
},
"4k": {
bitrate: 22.5,
quality: 37.2,
speed: 60,
efficiency: 1.65
}
},
"x264-superfast": {
color: "#4477DD",
"1080p": {
bitrate: 5.4,
quality: 37.8,
speed: 180,
efficiency: 7.00
},
"4k": {
bitrate: 19.2,
quality: 38.5,
speed: 48,
efficiency: 2.00
}
},
"x264-veryfast": {
color: "#5588EE",
"1080p": {
bitrate: 4.8,
quality: 38.7,
speed: 150,
efficiency: 8.06
},
"4k": {
bitrate: 17.5,
quality: 39.4,
speed: 40,
efficiency: 2.25
}
},
"x264-faster": {
color: "#6699FF",
"1080p": {
bitrate: 4.2,
quality: 39.6,
speed: 120,
efficiency: 9.43
},
"4k": {
bitrate: 15.8,
quality: 40.2,
speed: 32,
efficiency: 2.54
}
},
"x264-fast": {
color: "#77AAFF",
"1080p": {
bitrate: 3.8,
quality: 40.3,
speed: 90,
efficiency: 10.61
},
"4k": {
bitrate: 14.2,
quality: 41.0,
speed: 24,
efficiency: 2.89
}
},
"x264-medium": {
color: "#88BBFF",
"1080p": {
bitrate: 3.4,
quality: 41.2,
speed: 60,
efficiency: 12.12
},
"4k": {
bitrate: 12.8,
quality: 41.8,
speed: 16,
efficiency: 3.27
}
},
"x264-slow": {
color: "#99CCFF",
"1080p": {
bitrate: 3.0,
quality: 42.0,
speed: 35,
efficiency: 14.00
},
"4k": {
bitrate: 11.4,
quality: 42.6,
speed: 9,
efficiency: 3.74
}
},
"x264-slower": {
color: "#AADDFF",
"1080p": {
bitrate: 2.8,
quality: 42.6,
speed: 20,
efficiency: 15.21
},
"4k": {
bitrate: 10.6,
quality: 43.2,
speed: 5,
efficiency: 4.08
}
},
"x264-veryslow": {
color: "#BBEEFF",
"1080p": {
bitrate: 2.6,
quality: 43.2,
speed: 12,
efficiency: 16.62
},
"4k": {
bitrate: 9.8,
quality: 43.8,
speed: 3,
efficiency: 4.47
}
},
// NVIDIA Gen 1 presets
"NVENC-G1-fast": {
color: "#FF6600",
"1080p": {
bitrate: 5.2,
quality: 37.8,
speed: 200,
efficiency: 7.27
},
"4k": {
bitrate: 19.6,
quality: 38.6,
speed: 60,
efficiency: 1.97
}
},
"NVENC-G1-medium": {
color: "#FF8822",
"1080p": {
bitrate: 4.8,
quality: 38.5,
speed: 180,
efficiency: 8.02
},
"4k": {
bitrate: 18.2,
quality: 39.2,
speed: 52,
efficiency: 2.15
}
},
"NVENC-G1-slow": {
color: "#FFAA44",
"1080p": {
bitrate: 4.4,
quality: 39.2,
speed: 160,
efficiency: 8.91
},
"4k": {
bitrate: 16.8,
quality: 39.8,
speed: 45,
efficiency: 2.37
}
},
// NVIDIA Gen 2 presets
"NVENC-G2-fast": {
color: "#CC0000",
"1080p": {
bitrate: 4.8,
quality: 38.4,
speed: 240,
efficiency: 8.00
},
"4k": {
bitrate: 18.4,
quality: 39.2,
speed: 72,
efficiency: 2.13
}
},
"NVENC-G2-medium": {
color: "#EE0000",
"1080p": {
bitrate: 4.4,
quality: 39.2,
speed: 220,
efficiency: 8.91
},
"4k": {
bitrate: 16.6,
quality: 40.0,
speed: 65,
efficiency: 2.41
}
},
"NVENC-G2-slow": {
color: "#FF3333",
"1080p": {
bitrate: 4.0,
quality: 40.0,
speed: 200,
efficiency: 10.00
},
"4k": {
bitrate: 15.2,
quality: 40.8,
speed: 58,
efficiency: 2.68
}
},
// NVIDIA Gen 3 presets
"NVENC-G3-fast": {
color: "#006600",
"1080p": {
bitrate: 4.4,
quality: 39.2,
speed: 280,
efficiency: 8.91
},
"4k": {
bitrate: 16.8,
quality: 40.0,
speed: 90,
efficiency: 2.38
}
},
"NVENC-G3-medium": {
color: "#009900",
"1080p": {
bitrate: 4.0,
quality: 40.1,
speed: 260,
efficiency: 10.03
},
"4k": {
bitrate: 15.4,
quality: 40.8,
speed: 82,
efficiency: 2.65
}
},
"NVENC-G3-quality": {
color: "#00CC00",
"1080p": {
bitrate: 3.6,
quality: 40.8,
speed: 240,
efficiency: 11.33
},
"4k": {
bitrate: 14.0,
quality: 41.6,
speed: 75,
efficiency: 2.97
}
},
"NVENC-G3-highquality": {
color: "#00FF00",
"1080p": {
bitrate: 3.2,
quality: 41.5,
speed: 220,
efficiency: 12.97
},
"4k": {
bitrate: 12.6,
quality: 42.2,
speed: 68,
efficiency: 3.35
}
},
// AMD presets
"AMD-fastest": {
color: "#0066BB",
"1080p": {
bitrate: 5.0,
quality: 37.6,
speed: 220,
efficiency: 7.52
},
"4k": {
bitrate: 18.5,
quality: 38.4,
speed: 65,
efficiency: 2.08
}
},
"AMD-faster": {
color: "#0077CC",
"1080p": {
bitrate: 4.6,
quality: 38.4,
speed: 190,
efficiency: 8.35
},
"4k": {
bitrate: 17.2,
quality: 39.2,
speed: 55,
efficiency: 2.28
}
},
"AMD-fast": {
color: "#0088DD",
"1080p": {
bitrate: 4.2,
quality: 39.0,
speed: 160,
efficiency: 9.29
},
"4k": {
bitrate: 15.8,
quality: 39.8,
speed: 45,
efficiency: 2.52
}
},
"AMD-balanced": {
color: "#0099EE",
"1080p": {
bitrate: 3.8,
quality: 39.8,
speed: 130,
efficiency: 10.47
},
"4k": {
bitrate: 14.4,
quality: 40.5,
speed: 35,
efficiency: 2.81
}
},
"AMD-quality": {
color: "#00AAFF",
"1080p": {
bitrate: 3.4,
quality: 40.5,
speed: 100,
efficiency: 11.91
},
"4k": {
bitrate: 13.0,
quality: 41.2,
speed: 28,
efficiency: 3.17
}
},
"AMD-highquality": {
color: "#00BBFF",
"1080p": {
bitrate: 3.0,
quality: 41.2,
speed: 70,
efficiency: 13.73
},
"4k": {
bitrate: 11.6,
quality: 41.8,
speed: 20,
efficiency: 3.60
}
},
// Raspberry Pi
"RPi-fast": {
color: "#9900CC",
"1080p": {
bitrate: 6.5,
quality: 35.8,
speed: 35,
efficiency: 5.51
},
"4k": {
bitrate: 22.0,
quality: 36.5,
speed: 9,
efficiency: 1.66
}
},
"RPi-medium": {
color: "#AA33DD",
"1080p": {
bitrate: 6.0,
quality: 36.4,
speed: 28,
efficiency: 6.07
},
"4k": {
bitrate: 20.5,
quality: 37.0,
speed: 7,
efficiency: 1.80
}
},
"RPi-slow": {
color: "#BB66EE",
"1080p": {
bitrate: 5.5,
quality: 37.0,
speed: 20,
efficiency: 6.73
},
"4k": {
bitrate: 19.0,
quality: 37.6,
speed: 5,
efficiency: 1.98
}
}
};
// Category labels for X-axis (speed presets)
const categories = [
"x264 ultrafast",
"x264 superfast",
"x264 veryfast",
"x264 faster",
"x264 fast",
"x264 medium",
"x264 slow",
"x264 slower",
"x264 veryslow",
"NVENC G1 fast",
"NVENC G1 medium",
"NVENC G1 slow",
"NVENC G2 fast",
"NVENC G2 medium",
"NVENC G2 slow",
"NVENC G3 fast",
"NVENC G3 medium",
"NVENC G3 quality",
"NVENC G3 high quality",
"AMD fastest",
"AMD faster",
"AMD fast",
"AMD balanced",
"AMD quality",
"AMD high quality",
"RPi fast",
"RPi medium",
"RPi slow"
];
// Map encoders to category positions
const encoderMapping = {
"x264-ultrafast": 0,
"x264-superfast": 1,
"x264-veryfast": 2,
"x264-faster": 3,
"x264-fast": 4,
"x264-medium": 5,
"x264-slow": 6,
"x264-slower": 7,
"x264-veryslow": 8,
"NVENC-G1-fast": 9,
"NVENC-G1-medium": 10,
"NVENC-G1-slow": 11,
"NVENC-G2-fast": 12,
"NVENC-G2-medium": 13,
"NVENC-G2-slow": 14,
"NVENC-G3-fast": 15,
"NVENC-G3-medium": 16,
"NVENC-G3-quality": 17,
"NVENC-G3-highquality": 18,
"AMD-fastest": 19,
"AMD-faster": 20,
"AMD-fast": 21,
"AMD-balanced": 22,
"AMD-quality": 23,
"AMD-highquality": 24,
"RPi-fast": 25,
"RPi-medium": 26,
"RPi-slow": 27
};
// Update legend function to reflect new encoder groups
function updateLegend() {
const legendContainer = document.getElementById('legend');
legendContainer.innerHTML = '';
const groups = {
'x264 (FFMPEG)': ['x264-medium'], // Using medium as representative color
'NVIDIA G1': ['NVENC-G1-medium'], // Using medium as representative color
'NVIDIA G2': ['NVENC-G2-medium'], // Using medium as representative color
'NVIDIA G3': ['NVENC-G3-medium'], // Using medium as representative color
'AMD VCE': ['AMD-balanced'], // Using balanced as representative color
'Raspberry Pi': ['RPi-medium'] // Using medium as representative color
};
Object.keys(groups).forEach(group => {
const representativeEncoder = groups[group][0];
const div = document.createElement('div');
div.className = 'legend-item';
const colorBox = document.createElement('div');
colorBox.className = 'color-box';
colorBox.style.backgroundColor = encoderData[representativeEncoder].color;
const label = document.createElement('span');
label.textContent = group;
div.appendChild(colorBox);
div.appendChild(label);
legendContainer.appendChild(div);
});
}
// Set the default metric to efficiency
let currentMetric = 'efficiency';
let currentResolution = '1080p';
// Add this line after DOMContentLoaded to set the default metric in the UI:
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('metric-select').value = 'efficiency';
updateLegend();
updateChart();
});
// Chart configuration
let chart;
// Metric labels and configurations
const metricConfig = {
'bitrate': {
label: 'Bitrate (Mbps)',
yAxisLabel: 'Mbps (lower is better)',
inverse: true
},
'quality': {
label: 'Quality (PSNR dB)',
yAxisLabel: 'PSNR dB (higher is better)',
inverse: false
},
'speed': {
label: 'Encoding Speed (FPS)',
yAxisLabel: 'Frames Per Second (higher is better)',
inverse: false
},
'efficiency': {
label: 'Compression Efficiency (Quality/Bitrate)',
yAxisLabel: 'Efficiency Ratio (higher is better)',
inverse: false
}
};
// Function to update the chart
function updateChart() {
const ctx = document.getElementById('performanceChart').getContext('2d');
// Organize data for chart
const dataPoints = Array(categories.length).fill(null);
// Fill in data points based on encoder mapping
Object.keys(encoderData).forEach(encoder => {
const position = encoderMapping[encoder];
if (position !== undefined && encoderData[encoder][currentResolution]) {
dataPoints[position] = encoderData[encoder][currentResolution][currentMetric];
}
});
// Chart configuration
const chartConfig = {
type: 'bar',
data: {
labels: categories,
datasets: [{
label: metricConfig[currentMetric].label,
data: dataPoints,
backgroundColor: Object.keys(encoderData).map(encoder =>
encoderMapping[encoder] !== undefined ? encoderData[encoder].color : null
).filter(color => color !== null),
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: function(tooltipItems) {
return tooltipItems[0].label;
},
label: function(context) {
let value = context.raw;
if (currentMetric === 'bitrate') {
return `${value.toFixed(1)} Mbps (lower is better)`;
} else if (currentMetric === 'quality') {
return `${value.toFixed(1)} dB (higher is better)`;
} else if (currentMetric === 'speed') {
return `${value.toFixed(0)} FPS (higher is better)`;
} else if (currentMetric === 'efficiency') {
return `${value.toFixed(2)} (higher is better)`;
}
return value;
}
}
}
},
scales: {
y: {
beginAtZero: false,
title: {
display: true,
text: metricConfig[currentMetric].yAxisLabel
}
},
x: {
title: {
display: true,
text: 'Encoder Profile'
}
}
}
}
};
// Destroy previous chart if exists
if (chart) {
chart.destroy();
}
// Create new chart
chart = new Chart(ctx, chartConfig);
}
function updateLegend() {
const legendContainer = document.getElementById('legend');
legendContainer.innerHTML = '';
const groups = {
'x264 (FFMPEG)': ['x264-medium'], // Using medium as representative color
'NVIDIA G1': ['NVENC-G1-medium'], // Using medium as representative color
'NVIDIA G2': ['NVENC-G2-medium'], // Using medium as representative color
'NVIDIA G3': ['NVENC-G3-medium'], // Using medium as representative color
'AMD VCE': ['AMD-balanced'], // Using balanced as representative color
'Raspberry Pi': ['RPi-medium'] // Using medium as representative color
};
Object.keys(groups).forEach(group => {
const representativeEncoder = groups[group][0];
const div = document.createElement('div');
div.className = 'legend-item';
const colorBox = document.createElement('div');
colorBox.className = 'color-box';
colorBox.style.backgroundColor = encoderData[representativeEncoder].color;
const label = document.createElement('span');
label.textContent = group;
div.appendChild(colorBox);
div.appendChild(label);
legendContainer.appendChild(div);
});
}
// Event listeners for controls
document.getElementById('metric-select').addEventListener('change', function() {
currentMetric = this.value;
updateChart();
});
document.getElementById('resolution-select').addEventListener('change', function() {
currentResolution = this.value;
updateChart();
});
// Initialize chart and legend
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('metric-select').value = 'efficiency';
updateLegend();
updateChart();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

2417
comms.html Normal file

File diff suppressed because one or more lines are too long

162
confirm.html Normal file
View File

@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html>
<head>
<title>Confirm Navigation</title>
<style>
html {
background-color: #0000;
}
body {
font-family: Arial, sans-serif;
background-color: #333;
color: #fff;
text-align: center;
padding: 20px 0;
border: 10px dashed rgb(64 65 62);
}
h2 {
color: #f0f0f0;
}
p {
margin: 20px 0;
color: #d9d9d9;
}
#url {
word-wrap: break-word;
margin: 10px auto;
padding: 10px;
background-color: #444;
border: 1px solid #555;
border-radius: 4px;
width: 80%;
max-width: 600px;
}
button {
background-color: #555;
color: #fff;
border: none;
padding: 10px 20px;
margin: 10px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: #666;
}
button:active {
background-color: #777;
}
</style>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<meta content="utf-8" http-equiv="encoding" />
<meta name="copyright" content="&copy; 2023 Steve Seguin" />
<meta name="license" content="https://github.com/steveseguin/vdo.ninja/LICENSE.md" />
<meta name="sourcecode" content="https://github.com/steveseguin/vdo.ninja" />
<meta name="stance-on-war" content="Steve Seguin condemns Russia's brutal invasion of Ukraine 💙💛." />
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<link id="favicon1" rel="icon" type="image/png" sizes="32x32" href="./media/favicon-32x32.png" />
<link id="favicon2" rel="icon" type="image/png" sizes="16x16" href="./media/favicon-16x16.png" />
<link id="favicon3" rel="icon" href="./media/favicon.ico" />
<link id="thumbnailUrl" itemprop="thumbnailUrl" href="./media/vdoNinja_logo_full.png" />
<!-- Primary Meta Tags -->
<title>VDO.Ninja</title>
<meta id="metaTitle" name="title" content="VDO.Ninja" />
<meta name="description" content="Bring live video from your smartphone, computer, or friends directly into your Studio. 100% free." />
<meta name="author" content="Steve Seguin" />
</head>
<body>
<h2>Confirm External Page</h2>
<p>You are about to load to the following URL:</p>
<p id="url"></p>
<p>Loading untrusted links may reveal your IP address, location, or attempt to trick you</p>
<p>Do you wish to continue loading it?</p>
<button id="confirmBtn">Confirm</button>
<button id="cancelBtn">Cancel</button>
<script>
function hideURLPath(url) {
try {
var urlObject = new URL(url);
// Count the number of characters in pathname and search
var hiddenPartLength = urlObject.pathname.length + urlObject.search.length;
hiddenPartLength = Math.min(hiddenPartLength,25) - 1;
hiddenPartLength = Math.max(hiddenPartLength,0);
// Generate a string of asterisks of the same length
var hiddenPart = '*'.repeat(hiddenPartLength) || '****'; // Ensure there's at least four asterisks
return urlObject.origin + "/"+ hiddenPart;
} catch (e) {
return 'Invalid URL';
}
}
// Function to get URL parameters
function getParameterByName(name, url = window.location.href) {
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
// Function to validate and sanitize the URL
function validateAndSanitizeURL(url) {
// Check if the URL starts with http:// or https://
if (/^(https?:\/\/)/.test(url)) {
return url;
} else {
return null; // Invalid URL
}
}
// Get the URL parameter
var url = getParameterByName('url');
var clean = getParameterByName('clean');
// Validate and sanitize the URL
var sanitizedUrl = validateAndSanitizeURL(url);
// Set the URL in the paragraph
if (sanitizedUrl) {
if (clean!==null){
try {
document.getElementById('url').textContent = hideURLPath(sanitizedUrl);
} catch(e){
document.getElementById('url').textContent = sanitizedUrl;
}
} else {
document.getElementById('url').textContent = sanitizedUrl;
}
} else {
document.getElementById('url').textContent = 'Invalid URL';
}
// Confirm button event
document.getElementById('confirmBtn').addEventListener('click', function() {
if (sanitizedUrl) {
window.location.href = sanitizedUrl;
}
});
// Cancel button event
document.getElementById('cancelBtn').addEventListener('click', function() {
// Redirect to a safe page or simply do nothing
window.location.href = 'about:blank';
});
</script>
</body>
</html>

View File

@@ -1,10 +1,11 @@
<head>
<link rel="stylesheet" href="./main.css?ver=40" />
<link rel="stylesheet" href="./main.css" />
<style>
.container {
max-width: 80%;
max-width: min(80%,875px);
width: fit-content;
margin: 0 auto;
overflow: auto;
}
h1 {
@@ -13,6 +14,18 @@
padding: 10px;
}
a {
color: #00538c;
}
a:link {
color: #00538c;
}
a:visited {
color: #00538c;
}
.card {
margin: 10px;
box-shadow: 0 4px 8px 0 rgb(0 0 0 / 10%);
@@ -85,29 +98,43 @@
<div id="info">
<h1>Web-based Media Conversion Tools</h1>
<div class="card">
<h2>WebM to MP4 (fixed 1280x720 resolution) <span class='warning'>(very slow!)</span></h2>
<h2>WebM (or MKV/FLV) to MP4 (fixed 1280x720 resolution) <span class='warning'>(very slow!)</span></h2>
<div>
<small>The same as: fmpeg -i input.webm -vf scale=1280:720 output.mp4</small>
<input type="file" id="uploader" title="Convert WebM to MP4">
<small>The same as: <i>ffmpeg -i input.webm -vf scale="1280:720" output.mp4</i></small>
<input type="file" accept=".mkv, .flv, .webm" id="uploader" title="Convert WebM to MP4">
</div>
</div>
<div class="card">
<h2>WebM to MP4 files (no transcoding, attempts to force high resolutions)</h2>
<h2>WebM to MP4 files (no transcoding, *attempts* to force high resolutions)</h2>
<div>
<small>The same as: <i>ffmpeg.exe -i concat:"<a href='cap.webm' target="_blank">cap.webm</a>|input.webm" -safe 0 -c copy -avoid_negative_ts 1 -strict experimental output.mp4</i></small>
<input type="file" id="uploader3" accept=".webm" title="Convert WebM to MP4">
</div>
</div>
<div class="card">
<h2>WebM to Audio-only files (opus or wav)</h2>
<div>
<input type="file" id="uploader4" accept=".webm" title="Convert WebM to OPUS">
<small>The same as: <i>ffmpeg -i input.webm -vn -acodec copy output.wav</i></small>
<input type="file" id="uploader4" accept=".webm" title="Convert WebM to OPUS (or WAV)">
</div>
</div>
<div class="card">
<h2>MKV to MP4 (no transcoding)</h2>
<h2>MKV (or FLV/WebM) to MP4 (no transcoding)</h2>
<div>
<small>The same as: fmpeg -i INPUTFILE -vcodec copy -acodec copy output.mp4</small>
<input type="file" id="uploader2" accept=".mkv" title="Convert MKV to MP4">
<small>The same as: <i>ffmpeg -i INPUTFILE -vcodec copy -acodec copy output.mp4</i></small>
<input type="file" id="uploader2" accept=".mkv, .flv, .webm" title="Convert MKV (or FLV) to MP4">
</div>
</div>
<div class="card">
<h2>Having problems?</h2>
<div>
For larger files, over 2-gigabytes in size, the browser may not be able to properly process the video in memory.
</div>
<div>
Please consider using FFmpeg <a href='https://ffmpeg.org/download.html' target="_blank">[get it free here]</a> to run these processes from the command-line instead. The corresponding commands are provided above, where you need to replace input.webm with your own file.
</div>
<div>
Other users who find FFmpeg too challenging have had luck using the graphical <a href="https://handbrake.fr/" target="_blank">Handbrake</a> application instead.
</div>
</div>
<div id="processing">
@@ -126,6 +153,13 @@
<script src="./thirdparty/ffmpeg.min.js"></script>
<script>
window.onerror = function backupErr(errorMsg, url=false, lineNumber=false) {
console.error(errorMsg);
alert("An error occured.\n\nIf your file is larger than 2-GB, you may need to run the FFmpeg commands locally or use Handbrake instead.");
return false;
};
function download(data, filename) {
const blob = new Blob([data.buffer]);
const url = window.URL.createObjectURL(blob);
@@ -146,6 +180,11 @@
const transcode = async ({ target: { files } }) => {
const { name } = files[0];
console.log(files[0]);
if (files[0].size>2147483648){
alert("Warning: The largest file size currently supported is 2-GB.\n\nFor larger files, please instead consider using the FFmpeg commands locally or use Handbrake. ");
return;
}
document.getElementById('uploader').style.display = "none";
document.getElementById('uploader2').style.display = "none";
document.getElementById('uploader3').style.display = "none";
@@ -163,6 +202,10 @@
const transmux = async ({ target: { files } }) => {
const { name } = files[0];
if (files[0].size>2147483648){
alert("Warning: The largest file size currently supported is 2-GB.\n\nFor larger files, please instead consider using the FFmpeg commands locally or use Handbrake. ");
return;
}
document.getElementById('uploader').style.display = "none";
document.getElementById('uploader2').style.display = "none";
document.getElementById('uploader3').style.display = "none";
@@ -181,6 +224,10 @@
const force1080 = async ({ target: { files } }) => {
const { name } = files[0];
if (files[0].size>2147483648){
alert("Warning: The largest file size currently supported is 2-GB.\n\nFor larger files, please instead consider using the FFmpeg commands locally or use Handbrake. ");
return;
}
const sourceBuffer = await fetch("./media/cap.webm").then(r => r.arrayBuffer());
document.getElementById('uploader').style.display = "none";
document.getElementById('uploader2').style.display = "none";
@@ -202,6 +249,10 @@
const convertToAudioOnly = async ({ target: { files } }) => {
const { name } = files[0];
if (files[0].size>2147483648){
alert("Warning: The largest file size currently supported is 2-GB.\n\nFor larger files, please instead consider using the FFmpeg commands locally or use Handbrake. ");
return;
}
document.getElementById('message').innerText = "Transcoding file... this will take a while";
document.getElementById('processing').style.display = 'flex';
await ffmpeg.load();

View File

@@ -0,0 +1,69 @@
class MeterProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._peak = 0;
this._rms = 0;
this._clipped = 0;
this._frame = 0;
this._silentFrames = 0;
this._updateInterval = Math.round(sampleRate * 0.032);
}
process(inputs) {
const input = inputs[0];
if (!input || !input.length) {
this._frame += 128;
this._silentFrames += 1;
return true;
}
const channelData = input[0];
if (!channelData) {
this._frame += 128;
this._silentFrames += 1;
return true;
}
let peak = this._peak;
let sumSquares = 0;
let clipped = this._clipped;
for (let i = 0; i < channelData.length; i++) {
const sample = channelData[i];
const absSample = Math.abs(sample);
if (absSample > peak) {
peak = absSample;
}
if (absSample >= 0.89) {
clipped += 1;
}
sumSquares += sample * sample;
}
this._frame += channelData.length;
this._peak = peak;
this._clipped = clipped;
this._rms += sumSquares;
if (this._frame >= this._updateInterval) {
const rms = Math.sqrt(this._rms / this._frame);
const payload = {
peak,
rms,
clipped,
silent: rms <= 0.001,
timestamp: currentTime,
};
this.port.postMessage(payload);
this._peak = 0;
this._rms = 0;
this._clipped = 0;
this._frame = 0;
this._silentFrames = payload.silent ? this._silentFrames + 1 : 0;
}
return true;
}
}
registerProcessor('podcast-meter', MeterProcessor);

83
core/audio/meters.js Normal file
View File

@@ -0,0 +1,83 @@
import { levelBus } from '../events/level-bus.js';
const loadedWorklets = new WeakSet();
const DEFAULT_WORKLET_URL = new URL('./meter.worklet.js', import.meta.url).toString();
async function ensureWorkletModule(audioContext, workletUrl = DEFAULT_WORKLET_URL) {
if (loadedWorklets.has(audioContext)) {
return;
}
await audioContext.audioWorklet.addModule(workletUrl);
loadedWorklets.add(audioContext);
}
function createSource(audioContext, track) {
if (window.MediaStreamTrackAudioSourceNode) {
try {
return new MediaStreamTrackAudioSourceNode(audioContext, { track });
} catch (error) {
console.warn('Falling back to MediaStream source', error);
}
}
const stream = new MediaStream([track]);
return audioContext.createMediaStreamSource(stream);
}
const DEFAULT_ANALYSER_OPTIONS = {
fftSize: 512,
minDecibels: -110,
maxDecibels: -10,
smoothingTimeConstant: 0.75,
};
export async function monitorTrackLevel(audioContext, track, { uuid, trackType = 'audio', metadata = {} } = {}) {
if (!track || track.kind !== 'audio') {
throw new Error('monitorTrackLevel expects an audio MediaStreamTrack.');
}
await ensureWorkletModule(audioContext);
const source = createSource(audioContext, track);
const workletNode = new AudioWorkletNode(audioContext, 'podcast-meter');
const analyser = audioContext.createAnalyser();
analyser.fftSize = DEFAULT_ANALYSER_OPTIONS.fftSize;
analyser.minDecibels = DEFAULT_ANALYSER_OPTIONS.minDecibels;
analyser.maxDecibels = DEFAULT_ANALYSER_OPTIONS.maxDecibels;
analyser.smoothingTimeConstant = DEFAULT_ANALYSER_OPTIONS.smoothingTimeConstant;
const silentSink = new GainNode(audioContext, { gain: 0 });
source.connect(workletNode);
source.connect(analyser);
workletNode.connect(silentSink);
analyser.connect(silentSink);
silentSink.connect(audioContext.destination);
workletNode.port.onmessage = (event) => {
levelBus.publishLevel?.({
uuid,
trackType,
metadata,
...event.data,
});
};
return {
node: workletNode,
analyser,
source,
sink: silentSink,
disconnect: (options = {}) => {
try {
workletNode.port.onmessage = null;
workletNode.disconnect();
analyser.disconnect();
silentSink.disconnect();
} catch (error) {
console.warn('Failed to disconnect meter worklet node', error);
}
try {
source.disconnect();
} catch (error) {
console.warn('Failed to disconnect meter source', error);
}
if (options.stopTrack) {
track.stop();
}
},
};
}

14
core/events/event-bus.js Normal file
View File

@@ -0,0 +1,14 @@
export class EventBus extends EventTarget {
emit(type, detail = {}) {
const event = new CustomEvent(type, { detail });
this.dispatchEvent(event);
}
on(type, callback, options) {
const handler = (event) => callback(event.detail, event);
this.addEventListener(type, handler, options);
return () => this.removeEventListener(type, handler, options);
}
}
export const createScopedBus = () => new EventBus();

19
core/events/level-bus.js Normal file
View File

@@ -0,0 +1,19 @@
import { EventBus } from './event-bus.js';
export const LEVEL_EVENT = 'level';
export const CLIP_EVENT = 'clip';
export const SILENCE_EVENT = 'silence';
class LevelBus extends EventBus {
publishLevel(payload) {
this.emit(LEVEL_EVENT, payload);
if (payload?.clipped) {
this.emit(CLIP_EVENT, payload);
}
if (payload?.silent) {
this.emit(SILENCE_EVENT, payload);
}
}
}
export const levelBus = new LevelBus();

7
core/index.js Normal file
View File

@@ -0,0 +1,7 @@
export * from './events/event-bus.js';
export * from './events/level-bus.js';
export * from './legacy/session-bridge.js';
export { bridgeLegacyMeters } from './legacy/meter-bridge.js';
export * from './recording/index.js';
export * from './audio/meters.js';
export * from './uploads/index.js';

View File

@@ -0,0 +1,75 @@
import { waitForLegacySession } from './session-bridge.js';
import { levelBus } from '../events/level-bus.js';
const DEFAULT_INTERVAL_MS = 120;
const SILENCE_THRESHOLD = 2; // matches legacy behaviour where values < 2 are ignored
function normaliseLoudness(value) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return null;
}
const constrained = Math.max(0, value);
const peak = Math.min(1, constrained / 120);
const rms = Math.min(1, constrained / 160);
return { peak, rms, raw: constrained };
}
export async function bridgeLegacyMeters(options = {}) {
const { intervalMs = DEFAULT_INTERVAL_MS } = options;
const session = await waitForLegacySession({ timeoutMs: 15000 });
const lastValues = new Map();
function publish(uuid, loudness, metadata) {
if (typeof loudness !== 'number' || loudness < SILENCE_THRESHOLD) {
return;
}
const key = `${uuid}`;
if (lastValues.get(key) === loudness) {
return;
}
lastValues.set(key, loudness);
const values = normaliseLoudness(loudness);
if (!values) {
return;
}
levelBus.publishLevel({
uuid,
trackType: 'audio',
peak: values.peak,
rms: values.rms,
raw: values.raw,
source: 'legacy-meter',
timestamp: performance.now(),
metadata,
});
}
function poll() {
if (session.stats && typeof session.stats.Audio_Loudness === 'number') {
publish('local', session.stats.Audio_Loudness, { kind: 'local' });
}
Object.entries(session.rpcs || {}).forEach(([uuid, peer]) => {
if (!peer || !peer.stats) {
return;
}
const loudness = peer.stats.Audio_Loudness;
if (typeof loudness !== 'number') {
return;
}
publish(uuid, loudness, {
kind: 'remote',
streamID: peer.streamID,
label: peer.label,
});
});
}
const timer = setInterval(poll, intervalMs);
poll();
return () => {
clearInterval(timer);
lastValues.clear();
};
}

View File

@@ -0,0 +1,58 @@
const SESSION_POLL_MS = 25;
export function getLegacySession() {
if (typeof window === 'undefined') {
throw new Error('Session bridge requires a browser context.');
}
if (!window.session) {
throw new Error('Legacy session object is not initialised yet.');
}
return window.session;
}
export async function waitForLegacySession(options = {}) {
const { timeoutMs = 5000 } = options;
const start = performance.now();
while (true) {
if (window.session) {
return window.session;
}
if (performance.now() - start > timeoutMs) {
throw new Error('Timed out waiting for legacy session initialisation.');
}
await new Promise((resolve) => setTimeout(resolve, SESSION_POLL_MS));
}
}
export function onLegacyEvent(eventName, handler) {
const session = getLegacySession();
if (!session._podcastStudioListeners) {
session._podcastStudioListeners = new Map();
}
if (!session._podcastStudioListeners.has(eventName)) {
session._podcastStudioListeners.set(eventName, new Set());
}
const listeners = session._podcastStudioListeners.get(eventName);
listeners.add(handler);
return () => {
listeners.delete(handler);
};
}
// shim to forward events from legacy dispatchers
export function forwardLegacyEvent(eventName, payload) {
const session = getLegacySession();
const listeners = session._podcastStudioListeners?.get(eventName);
if (!listeners) {
return;
}
listeners.forEach((fn) => {
try {
fn(payload);
} catch (error) {
console.error('Legacy event listener failed', error);
}
});
}

3
core/recording/index.js Normal file
View File

@@ -0,0 +1,3 @@
export { MultiTrackRecorder } from './multitrack-recorder.js';
export { TrackRecorder } from './track-recorder.js';
export { convertBlobToWav, audioBufferToWav } from './wav-encoder.js';

View File

@@ -0,0 +1,440 @@
import { waitForLegacySession } from '../legacy/session-bridge.js';
import { monitorTrackLevel } from '../audio/meters.js';
import { TrackRecorder } from './track-recorder.js';
import { convertBlobToWav } from './wav-encoder.js';
const DEFAULT_OPTIONS = {
includeLocal: true,
includeRemotes: true,
includeScreenshares: false,
includeVideo: false,
mimeType: null,
timeslice: 0,
bitsPerSecond: null,
monitorLevels: true,
audioContext: null,
targetSampleRate: 48000,
filenamePrefix: 'podcast',
};
function sanitizeSegment(value, fallback = 'track') {
if (!value) {
return fallback;
}
return String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || fallback;
}
function extensionFromMime(mimeType) {
if (!mimeType) {
return 'bin';
}
if (mimeType.includes('wav')) {
return 'wav';
}
if (mimeType.includes('webm')) {
return 'webm';
}
if (mimeType.includes('ogg')) {
return 'ogg';
}
if (mimeType.includes('mp4')) {
return 'mp4';
}
if (mimeType.includes('m4a')) {
return 'm4a';
}
return 'bin';
}
function buildTimestamp(epoch = Date.now()) {
return new Date(epoch).toISOString().replace(/[:.]/g, '-');
}
function safeCloneTrack(track) {
try {
return track.clone();
} catch (error) {
console.warn('Unable to clone track, using original reference', error);
return track;
}
}
function gatherTracksFromStream(stream, { includeVideo }) {
if (!stream) {
return { audio: [], video: [] };
}
const audio = stream.getAudioTracks ? stream.getAudioTracks() : [];
const video = includeVideo && stream.getVideoTracks ? stream.getVideoTracks() : [];
return {
audio: audio.filter(Boolean),
video: video.filter(Boolean),
};
}
export class MultiTrackRecorder extends EventTarget {
constructor(options = {}) {
super();
this.options = { ...DEFAULT_OPTIONS, ...options };
this.sessionPromise = null;
this.session = null;
this.recorders = new Map();
this.files = new Map();
this.trackMeters = new Map();
this.trackStartTimes = new Map(); // Per-track start times relative to session start
this.startedAt = null;
}
async ensureSession() {
if (this.session) {
return this.session;
}
if (!this.sessionPromise) {
this.sessionPromise = waitForLegacySession();
}
this.session = await this.sessionPromise;
return this.session;
}
async listRecordableParticipants() {
const session = await this.ensureSession();
const participants = [];
if (this.options.includeLocal && session.streamSrc) {
participants.push({
uuid: 'local',
kind: 'local',
label: session.label || 'Host',
stream: session.streamSrc,
streamID: session.streamID,
});
}
if (this.options.includeRemotes && session.rpcs) {
Object.entries(session.rpcs).forEach(([uuid, peer]) => {
if (!peer) {
return;
}
const label = peer.label || peer.streamID || uuid;
const stream = peer.streamSrc || peer.stream || peer.videoElement?.srcObject;
if (!stream) {
return;
}
participants.push({
uuid,
kind: 'remote',
label,
stream,
streamID: peer.streamID,
});
if (this.options.includeScreenshares && peer.screenShareStream) {
participants.push({
uuid: `${uuid}:screen`,
kind: 'screenshare',
label: `${label} (Screen)`,
stream: peer.screenShareStream,
streamID: peer.streamID ? `${peer.streamID}:screen` : `${uuid}:screen`,
});
}
});
}
return participants;
}
createTrackRecorders(participant, options, startOffsetSeconds = 0) {
const { includeVideo, mimeType, bitsPerSecond, timeslice, monitorLevels, audioContext } = options;
const { audio, video } = gatherTracksFromStream(participant.stream, { includeVideo });
const recorders = [];
const trackStartOffset = Number.isFinite(startOffsetSeconds) ? Math.max(0, startOffsetSeconds) : 0;
audio.forEach((track, index) => {
const cloned = safeCloneTrack(track);
const recorder = new TrackRecorder({
track: cloned,
uuid: participant.uuid,
label: `${participant.label || participant.uuid}#${index + 1}`,
kind: 'audio',
mimeType,
});
recorder.start({ timeslice, bitsPerSecond });
recorders.push({ recorder, trackType: 'audio', channelIndex: index, startOffsetSeconds: trackStartOffset });
if (monitorLevels && audioContext) {
monitorTrackLevel(audioContext, cloned, {
uuid: participant.uuid,
trackType: 'audio',
metadata: { channelIndex: index, label: participant.label },
})
.then((meter) => {
const meterKey = `${participant.uuid}:audio:${index}`;
this.trackMeters.set(meterKey, meter);
this.dispatchEvent(
new CustomEvent('meter-ready', {
detail: {
participant,
trackType: 'audio',
channelIndex: index,
meter,
},
}),
);
})
.catch((error) => {
console.warn('Failed to attach meter to track', error);
});
}
});
video.forEach((track, index) => {
const cloned = safeCloneTrack(track);
const recorder = new TrackRecorder({
track: cloned,
uuid: participant.uuid,
label: `${participant.label || participant.uuid}-video#${index + 1}`,
kind: 'video',
mimeType: null,
});
recorder.start({ timeslice, bitsPerSecond });
recorders.push({ recorder, trackType: 'video', channelIndex: index, startOffsetSeconds: trackStartOffset });
});
return recorders;
}
attachRecorderHandlers(participant, recorders) {
recorders.forEach(({ recorder, trackType, channelIndex, startOffsetSeconds }) => {
const key = `${participant.uuid}:${trackType}:${channelIndex}`;
this.recorders.set(key, recorder);
this.trackStartTimes.set(key, startOffsetSeconds);
recorder.addEventListener('data', (event) => {
this.dispatchEvent(
new CustomEvent('chunk', {
detail: {
participant,
trackType,
channelIndex,
data: event.detail,
},
}),
);
});
recorder.addEventListener('error', (event) => {
this.dispatchEvent(
new CustomEvent('error', {
detail: {
participant,
trackType,
channelIndex,
error: event.detail,
},
}),
);
});
recorder.addEventListener('stop', () => {
const blob = recorder.toBlob();
if (blob) {
const fileKey = `${participant.uuid}:${trackType}:${channelIndex}`;
this.files.set(fileKey, {
blob,
originalBlob: blob,
participant,
trackType,
channelIndex,
mimeType: recorder.mimeType,
originalMimeType: recorder.mimeType,
durationSeconds: typeof recorder.getDurationSeconds === 'function' ? recorder.getDurationSeconds() : null,
recorderLabel: recorder.label,
startOffsetSeconds,
});
}
this.dispatchEvent(
new CustomEvent('track-stopped', {
detail: {
participant,
trackType,
channelIndex,
},
}),
);
});
});
}
addParticipant(participant) {
if (!this.startedAt) {
throw new Error('Cannot add participant: recording not started.');
}
if (!participant || !participant.stream) {
throw new Error('Cannot add participant: missing stream.');
}
const startOffsetSeconds = (Date.now() - this.startedAt) / 1000;
const recorders = this.createTrackRecorders(participant, this.options, startOffsetSeconds);
if (!recorders.length) {
return { added: false, tracks: 0, startOffsetSeconds };
}
this.attachRecorderHandlers(participant, recorders);
this.dispatchEvent(
new CustomEvent('participant-added', {
detail: {
participant,
trackCount: recorders.length,
startOffsetSeconds,
},
}),
);
return { added: true, tracks: recorders.length, startOffsetSeconds };
}
isRecording() {
return this.startedAt !== null && this.recorders.size > 0;
}
async start(customOptions = {}) {
const options = { ...this.options, ...customOptions };
this.options = options;
this.files.clear();
this.startedAt = Date.now();
if (this.recorders.size) {
throw new Error('MultiTrackRecorder already running.');
}
const participants = await this.listRecordableParticipants();
const extras = Array.isArray(options.extraParticipants)
? options.extraParticipants
.map((participant, index) => {
if (!participant || !participant.stream) {
return null;
}
const uuid = participant.uuid || `external-${index}`;
return {
uuid,
kind: participant.kind || 'external',
label: participant.label || uuid,
stream: participant.stream,
streamID: participant.streamID || uuid,
external: true,
};
})
.filter(Boolean)
: [];
const participantLookup = new Map();
participants.forEach((participant) => {
if (participant && participant.uuid) {
participantLookup.set(participant.uuid, participant);
}
});
extras.forEach((participant) => {
if (!participantLookup.has(participant.uuid)) {
participants.push(participant);
participantLookup.set(participant.uuid, participant);
}
});
if (!participants.length) {
throw new Error('No recordable participants found.');
}
participants.forEach((participant) => {
const recorders = this.createTrackRecorders(participant, options, 0);
this.attachRecorderHandlers(participant, recorders);
});
this.dispatchEvent(new CustomEvent('start', { detail: { participants, startedAt: this.startedAt } }));
}
async stop({ markers = null } = {}) {
const stops = [];
this.recorders.forEach((recorder) => {
stops.push(recorder.stop());
});
this.recorders.clear();
const meterStops = [];
this.trackMeters.forEach((meter) => {
if (meter && typeof meter.disconnect === 'function') {
meterStops.push(Promise.resolve().then(() => meter.disconnect()));
}
});
this.trackMeters.clear();
this.trackStartTimes.clear();
await Promise.allSettled(stops);
await Promise.allSettled(meterStops);
await this.packageAudioFiles({ markers });
const packaged = this.files;
this.startedAt = null;
this.dispatchEvent(new CustomEvent('stop', { detail: { files: packaged } }));
return packaged;
}
getFiles() {
return this.files;
}
getTrackMeter(uuid, trackType = 'audio', channelIndex = 0) {
if (!uuid) {
return null;
}
const key = `${uuid}:${trackType}:${channelIndex}`;
return this.trackMeters.get(key) || null;
}
async packageAudioFiles({ markers = null } = {}) {
if (!this.files.size) {
return this.files;
}
const conversions = [];
this.files.forEach((meta, key) => {
const fileMeta = meta;
if (!fileMeta.filename) {
fileMeta.filename = this.generateFilename(fileMeta);
}
if (fileMeta.trackType !== 'audio' || !fileMeta.blob) {
// For non-audio tracks we still normalise mime type/filename
fileMeta.mimeType = fileMeta.mimeType || fileMeta.originalMimeType || fileMeta.blob?.type || 'application/octet-stream';
fileMeta.size = fileMeta.blob?.size || fileMeta.originalBlob?.size || 0;
return;
}
conversions.push(
(async () => {
try {
const trackStartOffsetSeconds = fileMeta.startOffsetSeconds || 0;
const wavBlob = await convertBlobToWav(fileMeta.blob, {
sampleRate: this.options.targetSampleRate,
markers,
trackStartOffsetSeconds,
audioContext: this.options.audioContext,
});
fileMeta.originalBlob = fileMeta.originalBlob || fileMeta.blob;
fileMeta.originalMimeType = fileMeta.originalMimeType || fileMeta.mimeType;
fileMeta.blob = wavBlob;
fileMeta.mimeType = 'audio/wav';
fileMeta.filename = this.generateFilename(fileMeta, { extension: 'wav' });
fileMeta.size = wavBlob.size;
} catch (error) {
console.warn('Failed to package track as WAV, falling back to original blob', error);
const extension = extensionFromMime(fileMeta.mimeType || fileMeta.originalMimeType || fileMeta.blob?.type);
fileMeta.filename = this.generateFilename(fileMeta, { extension });
fileMeta.packagingError = error;
fileMeta.size = fileMeta.blob?.size || fileMeta.originalBlob?.size || 0;
}
})(),
);
});
await Promise.allSettled(conversions);
return this.files;
}
generateFilename(meta, { extension } = {}) {
const prefix = sanitizeSegment(this.options.filenamePrefix, 'podcast');
const room = sanitizeSegment(this.session?.roomid || this.session?.roomID || 'room');
const participantLabel = sanitizeSegment(meta.participant?.streamID || meta.participant?.label || meta.participant?.uuid, meta.participant?.uuid || 'participant');
const trackKind = sanitizeSegment(meta.trackType || 'track');
const channel = typeof meta.channelIndex === 'number' ? `c${meta.channelIndex + 1}` : 'c1';
const timestamp = buildTimestamp(this.startedAt);
const ext = extension || extensionFromMime(meta.mimeType || meta.originalMimeType || 'audio/wav');
return `${prefix}-${room}-${participantLabel}-${trackKind}-${channel}-${timestamp}.${ext}`;
}
}

View File

@@ -0,0 +1,134 @@
const DEFAULT_AUDIO_MIME_TYPES = [
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/webm',
'audio/ogg',
];
export class TrackRecorder extends EventTarget {
constructor({ track, uuid, label, kind = 'audio', mimeType }) {
super();
if (!track) {
throw new Error('TrackRecorder requires a MediaStreamTrack.');
}
this.track = track;
this.uuid = uuid;
this.label = label;
this.kind = kind;
this.mimeType = mimeType || TrackRecorder.pickSupportedMime(kind);
this.mediaRecorder = null;
this.chunks = [];
this.startedAt = null;
this.stoppedAt = null;
this.stopResolver = null;
this.stopComplete = null;
}
static pickSupportedMime(kind) {
if (typeof MediaRecorder === 'undefined') {
return null;
}
if (kind === 'video') {
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
];
return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || null;
}
return DEFAULT_AUDIO_MIME_TYPES.find((type) => MediaRecorder.isTypeSupported(type)) || null;
}
createStream() {
const stream = new MediaStream();
stream.addTrack(this.track);
return stream;
}
start(options = {}) {
if (this.mediaRecorder) {
throw new Error('TrackRecorder already started.');
}
const stream = this.createStream();
const recorderOptions = {};
if (this.mimeType) {
recorderOptions.mimeType = this.mimeType;
}
if (options.bitsPerSecond) {
recorderOptions.bitsPerSecond = options.bitsPerSecond;
}
this.mediaRecorder = new MediaRecorder(stream, recorderOptions);
this.chunks = [];
this.startedAt = performance.now();
this.stoppedAt = null;
this.stopComplete = new Promise((resolve) => {
this.stopResolver = resolve;
});
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
this.dispatchEvent(new CustomEvent('data', { detail: event.data }));
}
};
this.mediaRecorder.onstop = (event) => {
this.stoppedAt = performance.now();
if (this.stopResolver) {
this.stopResolver();
this.stopResolver = null;
}
this.dispatchEvent(new CustomEvent('stop', { detail: event }));
};
this.mediaRecorder.onerror = (event) => {
this.dispatchEvent(new CustomEvent('error', { detail: event.error || event }));
};
this.mediaRecorder.start(options.timeslice || 0);
}
stop() {
if (!this.mediaRecorder) {
return Promise.resolve();
}
const completion = this.stopComplete || Promise.resolve();
if (this.mediaRecorder.state !== 'inactive') {
try {
this.mediaRecorder.stop();
} catch (error) {
console.warn('MediaRecorder stop failed', error);
}
}
return completion
.catch((error) => {
console.warn('MediaRecorder stop did not resolve cleanly', error);
})
.finally(() => {
try {
this.track.stop();
} catch (error) {
console.warn('Failed to stop cloned track', error);
}
this.mediaRecorder = null;
this.stopComplete = null;
});
}
toBlob() {
if (!this.chunks.length) {
return null;
}
const mimeType = this.mimeType || (this.kind === 'audio' ? 'audio/webm' : 'video/webm');
return new Blob(this.chunks, { type: mimeType });
}
getDurationSeconds() {
if (!this.startedAt) {
return 0;
}
const end = this.stoppedAt || performance.now();
return (end - this.startedAt) / 1000;
}
}

View File

@@ -0,0 +1,321 @@
const DEFAULT_SAMPLE_RATE = 48000;
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i += 1) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
function padToEven(value) {
if (!Number.isFinite(value)) {
return 0;
}
return value % 2 === 0 ? value : value + 1;
}
function sanitiseMarkerLabel(label, fallback = 'Marker') {
if (!label) {
return fallback;
}
if (typeof label !== 'string') {
return fallback;
}
return label.replace(/\0/g, '').replace(/\r?\n/g, ' ').trim() || fallback;
}
function normaliseCueMarkers(markers, sampleRate, maxSampleOffset, trackStartOffsetSeconds = 0) {
if (!Array.isArray(markers) || !markers.length || !Number.isFinite(sampleRate) || sampleRate <= 0) {
return [];
}
const trackOffset = Number.isFinite(trackStartOffsetSeconds) ? Math.max(0, trackStartOffsetSeconds) : 0;
const cueMarkers = [];
for (let i = 0; i < markers.length; i += 1) {
const marker = markers[i];
if (!marker) {
continue;
}
const rawTimeSeconds =
marker.timeSeconds ??
marker.time ??
marker.seconds ??
marker.t ??
(typeof marker.timestamp === 'number' ? marker.timestamp : undefined);
if (!Number.isFinite(rawTimeSeconds)) {
continue;
}
// Adjust marker time relative to this track's start
const adjustedSeconds = rawTimeSeconds - trackOffset;
// Skip markers that occurred before this track started
if (adjustedSeconds < 0) {
continue;
}
let sampleOffset = Math.round(adjustedSeconds * sampleRate);
if (Number.isFinite(maxSampleOffset)) {
sampleOffset = Math.min(Math.max(0, sampleOffset), maxSampleOffset);
} else {
sampleOffset = Math.max(0, sampleOffset);
}
cueMarkers.push({
timeSeconds: rawTimeSeconds,
adjustedSeconds,
sampleOffset,
label: sanitiseMarkerLabel(marker.label, `Marker ${cueMarkers.length + 1}`),
});
}
cueMarkers.sort((a, b) => a.sampleOffset - b.sampleOffset);
return cueMarkers.map((marker, index) => ({
...marker,
id: index + 1,
}));
}
function buildCueChunk(cueMarkers) {
if (!cueMarkers.length) {
return null;
}
const cuePointsSize = 4 + cueMarkers.length * 24;
const cueChunkSize = padToEven(cuePointsSize);
const buffer = new ArrayBuffer(8 + cueChunkSize);
const view = new DataView(buffer);
writeString(view, 0, 'cue ');
view.setUint32(4, cuePointsSize, true);
view.setUint32(8, cueMarkers.length, true);
let offset = 12;
cueMarkers.forEach((marker) => {
view.setUint32(offset, marker.id, true);
offset += 4;
view.setUint32(offset, marker.sampleOffset, true);
offset += 4;
writeString(view, offset, 'data');
offset += 4;
view.setUint32(offset, 0, true);
offset += 4;
view.setUint32(offset, 0, true);
offset += 4;
view.setUint32(offset, marker.sampleOffset, true);
offset += 4;
});
return new Uint8Array(buffer);
}
function buildAdtlListChunk(cueMarkers) {
if (!cueMarkers.length) {
return null;
}
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
if (!encoder) {
return null;
}
const labels = cueMarkers.map((marker) => {
const labelBytes = encoder.encode(sanitiseMarkerLabel(marker.label, `Marker ${marker.id}`));
const dataSize = 4 + labelBytes.length + 1;
return {
id: marker.id,
labelBytes,
dataSize,
paddedDataSize: padToEven(dataSize),
};
});
const listDataSize = 4 + labels.reduce((total, entry) => total + 8 + entry.paddedDataSize, 0);
const listChunkSize = padToEven(listDataSize);
const buffer = new ArrayBuffer(8 + listChunkSize);
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
writeString(view, 0, 'LIST');
view.setUint32(4, listDataSize, true);
writeString(view, 8, 'adtl');
let offset = 12;
labels.forEach((entry) => {
writeString(view, offset, 'labl');
offset += 4;
view.setUint32(offset, entry.dataSize, true);
offset += 4;
view.setUint32(offset, entry.id, true);
offset += 4;
bytes.set(entry.labelBytes, offset);
offset += entry.labelBytes.length;
bytes[offset] = 0;
offset += 1;
const padding = entry.paddedDataSize - entry.dataSize;
offset += padding;
});
return new Uint8Array(buffer);
}
function interleaveChannels(channelData) {
if (!channelData.length) {
return new Float32Array();
}
const length = channelData[0].length;
if (channelData.length === 1) {
return new Float32Array(channelData[0]);
}
const interleaved = new Float32Array(length * channelData.length);
let index = 0;
for (let i = 0; i < length; i += 1) {
for (let channel = 0; channel < channelData.length; channel += 1) {
interleaved[index] = channelData[channel][i];
index += 1;
}
}
return interleaved;
}
function floatTo16BitPCM(view, offset, input) {
for (let i = 0; i < input.length; i += 1, offset += 2) {
let sample = input[i];
sample = Math.max(-1, Math.min(1, sample));
const converted = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
view.setInt16(offset, converted, true);
}
}
export function audioBufferToWav(audioBuffer, { float32 = false, markers = null, trackStartOffsetSeconds = 0 } = {}) {
if (!audioBuffer) {
throw new Error('audioBufferToWav expects an AudioBuffer.');
}
const numberOfChannels = audioBuffer.numberOfChannels || 1;
const sampleRate = audioBuffer.sampleRate || DEFAULT_SAMPLE_RATE;
const channelData = [];
for (let i = 0; i < numberOfChannels; i += 1) {
channelData.push(audioBuffer.getChannelData(i));
}
const interleaved = interleaveChannels(channelData);
const bytesPerSample = float32 ? 4 : 2;
const format = float32 ? 3 : 1;
const dataLength = interleaved.length * bytesPerSample;
const cueMarkers = normaliseCueMarkers(markers, sampleRate, audioBuffer.length ? Math.max(0, audioBuffer.length - 1) : 0, trackStartOffsetSeconds);
const cueChunk = buildCueChunk(cueMarkers);
const listChunk = buildAdtlListChunk(cueMarkers);
const riffHeaderSize = 12;
const fmtChunkTotal = 8 + 16;
const dataChunkTotal = 8 + padToEven(dataLength);
const cueChunkTotal = cueChunk ? cueChunk.length : 0;
const listChunkTotal = listChunk ? listChunk.length : 0;
const totalSize = riffHeaderSize + fmtChunkTotal + dataChunkTotal + cueChunkTotal + listChunkTotal;
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
writeString(view, 0, 'RIFF');
view.setUint32(4, totalSize - 8, true);
writeString(view, 8, 'WAVE');
let offset = 12;
writeString(view, offset, 'fmt ');
view.setUint32(offset + 4, 16, true);
view.setUint16(offset + 8, format, true);
view.setUint16(offset + 10, numberOfChannels, true);
view.setUint32(offset + 12, sampleRate, true);
view.setUint32(offset + 16, sampleRate * numberOfChannels * bytesPerSample, true);
view.setUint16(offset + 20, numberOfChannels * bytesPerSample, true);
view.setUint16(offset + 22, bytesPerSample * 8, true);
offset += fmtChunkTotal;
writeString(view, offset, 'data');
view.setUint32(offset + 4, dataLength, true);
const dataOffset = offset + 8;
if (float32) {
const floatView = new Float32Array(buffer, dataOffset, interleaved.length);
floatView.set(interleaved);
} else {
floatTo16BitPCM(view, dataOffset, interleaved);
}
offset += 8 + padToEven(dataLength);
if (cueChunk) {
bytes.set(cueChunk, offset);
offset += cueChunk.length;
}
if (listChunk) {
bytes.set(listChunk, offset);
offset += listChunk.length;
}
return buffer;
}
async function resampleIfNeeded(audioBuffer, targetSampleRate) {
if (!targetSampleRate || !audioBuffer) {
return audioBuffer;
}
if (Math.abs(audioBuffer.sampleRate - targetSampleRate) < 1) {
return audioBuffer;
}
if (typeof OfflineAudioContext === 'undefined') {
return audioBuffer;
}
const length = Math.ceil(audioBuffer.duration * targetSampleRate);
const offline = new OfflineAudioContext(audioBuffer.numberOfChannels, length, targetSampleRate);
const source = offline.createBufferSource();
source.buffer = audioBuffer;
source.connect(offline.destination);
source.start(0);
return offline.startRendering();
}
export async function convertBlobToWav(blob, { sampleRate = DEFAULT_SAMPLE_RATE, float32 = false, markers = null, trackStartOffsetSeconds = 0, audioContext = null } = {}) {
if (!blob) {
throw new Error('convertBlobToWav expects a Blob.');
}
if (typeof window === 'undefined') {
throw new Error('convertBlobToWav requires a browser environment.');
}
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (!audioContext && !AudioContextCtor) {
throw new Error('AudioContext not supported in this environment.');
}
const context = audioContext || new AudioContextCtor();
const shouldCloseContext = !audioContext && typeof context.close === 'function';
let audioBuffer;
try {
const arrayBuffer = await blob.arrayBuffer();
const bufferCopy = arrayBuffer.slice(0);
audioBuffer = await new Promise((resolve, reject) => {
context.decodeAudioData(bufferCopy, resolve, reject);
});
} catch (error) {
if (shouldCloseContext) {
await context.close();
}
throw error;
}
if (shouldCloseContext) {
await context.close();
}
let processedBuffer = audioBuffer;
try {
processedBuffer = await resampleIfNeeded(audioBuffer, sampleRate);
} catch (error) {
console.warn('Failed to resample audio buffer, using original sample rate', error);
}
const wavArrayBuffer = audioBufferToWav(processedBuffer, { float32, markers, trackStartOffsetSeconds });
return new Blob([wavArrayBuffer], { type: 'audio/wav' });
}

View File

@@ -0,0 +1,242 @@
import { waitForLegacySession } from '../legacy/session-bridge.js';
const DRIVE_CHUNK_ALIGNMENT = 256 * 1024;
const DEFAULT_DRIVE_CHUNK_SIZE = 4 * 1024 * 1024;
const DEFAULT_DROPBOX_CHUNK_SIZE = 8 * 1024 * 1024;
function createAbortError() {
const error = new Error('Aborted');
error.name = 'AbortError';
return error;
}
export class CloudUploadCoordinator {
constructor(session) {
this.session = session;
}
static async create() {
const session = await waitForLegacySession();
return new CloudUploadCoordinator(session);
}
ensureDriveClient() {
if (!this.session.gdrive && typeof window.setupGoogleDriveUploader === 'function') {
this.session.gdrive = window.setupGoogleDriveUploader();
}
return this.session.gdrive || null;
}
async ensureDropboxClient(token, options = {}) {
let opts = options;
if (typeof token === 'object' && token !== null && !Array.isArray(token)) {
opts = token;
token = undefined;
}
opts = opts || {};
const forceReauth = Boolean(opts.forceReauth);
if (typeof token === 'string' && token.trim().length) {
token = token.trim();
}
const manualTokenProvided = typeof token === 'string' && token.length > 0;
const previousClient = this.session.dbx || null;
const oauthRecord =
(this.session && this.session.dropboxOAuth) ||
(typeof session !== 'undefined' ? session.dropboxOAuth : null) ||
null;
const oauthFresh = Boolean(
oauthRecord && (!oauthRecord.expiresAt || Date.now() < oauthRecord.expiresAt),
);
if (!forceReauth && !manualTokenProvided && this.session.dbx && oauthFresh) {
return this.session.dbx;
}
if (typeof window.setupDropbox !== 'function') {
return this.session.dbx || null;
}
try {
const client = await window.setupDropbox(token, opts);
if (client) {
this.session.dbx = client;
return client;
}
} catch (error) {
if (forceReauth && previousClient && !this.session.dbx) {
this.session.dbx = previousClient;
}
throw error;
}
if (!this.session.dbx && previousClient) {
this.session.dbx = previousClient;
}
return this.session.dbx || null;
}
startDriveUpload(filename, sessionUri) {
if (typeof window.setupGoogleDriveUploader !== 'function') {
throw new Error('Google Drive uploader is not available in this build.');
}
return window.setupGoogleDriveUploader(filename, sessionUri);
}
createDriveChunkWriter(filename, sessionUri) {
const uploader = this.startDriveUpload(filename, sessionUri);
return {
addChunk: (chunk) => uploader?.addChunk?.(chunk),
finalize: () => uploader?.finalize?.(),
uploader,
};
}
createDropboxChunkWriter(filename) {
if (typeof window.streamVideoToDropbox !== 'function') {
throw new Error('Dropbox uploader is not available in this build.');
}
return window.streamVideoToDropbox(filename);
}
hasDriveAccess() {
return Boolean(this.session.gdrive && this.session.gdrive.accessToken);
}
hasDropboxAccess() {
return Boolean(this.session.dbx);
}
async uploadBlob(blob, options = {}) {
if (!blob) {
throw new Error('uploadBlob expects a Blob.');
}
const {
filename,
drive = true,
dropbox = true,
onProgress,
signal,
driveChunkSize = DEFAULT_DRIVE_CHUNK_SIZE,
dropboxChunkSize = DEFAULT_DROPBOX_CHUNK_SIZE,
} = options;
const results = {};
if (drive) {
try {
results.drive = await this.uploadBlobToDrive(blob, {
filename,
onProgress,
signal,
chunkSize: driveChunkSize,
});
} catch (error) {
results.drive = { status: 'error', service: 'drive', error };
}
} else {
results.drive = { status: 'skipped', service: 'drive', reason: 'disabled' };
}
if (dropbox) {
try {
results.dropbox = await this.uploadBlobToDropbox(blob, {
filename,
onProgress,
signal,
chunkSize: dropboxChunkSize,
});
} catch (error) {
results.dropbox = { status: 'error', service: 'dropbox', error };
}
} else {
results.dropbox = { status: 'skipped', service: 'dropbox', reason: 'disabled' };
}
return results;
}
async uploadBlobToDrive(blob, { filename, onProgress, signal, chunkSize = DEFAULT_DRIVE_CHUNK_SIZE } = {}) {
const client = this.ensureDriveClient();
if (!client) {
return { status: 'skipped', service: 'drive', reason: 'unavailable' };
}
if (signal?.aborted) {
throw createAbortError();
}
const name = filename || `recording-${Date.now()}.wav`;
let writer;
try {
writer = this.createDriveChunkWriter(name, this.session.gdrive?.sessionUri);
} catch (error) {
return { status: 'error', service: 'drive', error };
}
if (!writer?.addChunk) {
return { status: 'error', service: 'drive', error: new Error('Drive writer unavailable') };
}
const total = blob.size || 0;
const alignment = DRIVE_CHUNK_ALIGNMENT;
const adjustedChunkSize = Math.max(alignment, Math.floor(chunkSize / alignment) * alignment);
let uploaded = 0;
for (let offset = 0; offset < total; offset += adjustedChunkSize) {
if (signal?.aborted) {
throw createAbortError();
}
const chunk = blob.slice(offset, Math.min(total, offset + adjustedChunkSize), blob.type || 'application/octet-stream');
writer.addChunk(chunk);
uploaded += chunk.size;
if (typeof onProgress === 'function') {
onProgress({
service: 'drive',
uploaded,
total,
percentage: total ? Math.min(100, Math.round((uploaded / total) * 100)) : 0,
});
}
}
writer.addChunk(false);
if (typeof writer.finalize === 'function') {
try {
await writer.finalize();
} catch (error) {
return { status: 'error', service: 'drive', error };
}
}
return { status: 'uploaded', service: 'drive', filename: name, bytes: uploaded };
}
async uploadBlobToDropbox(blob, { filename, onProgress, signal, chunkSize = DEFAULT_DROPBOX_CHUNK_SIZE } = {}) {
const client = await this.ensureDropboxClient();
if (!client) {
return { status: 'skipped', service: 'dropbox', reason: 'unavailable' };
}
if (signal?.aborted) {
throw createAbortError();
}
let writer;
const name = filename || `recording-${Date.now()}.wav`;
try {
writer = await this.createDropboxChunkWriter(name);
} catch (error) {
return { status: 'error', service: 'dropbox', error };
}
if (typeof writer !== 'function') {
return { status: 'error', service: 'dropbox', error: new Error('Dropbox writer unavailable') };
}
const total = blob.size || 0;
let uploaded = 0;
for (let offset = 0; offset < total; offset += chunkSize) {
if (signal?.aborted) {
throw createAbortError();
}
const chunk = blob.slice(offset, Math.min(total, offset + chunkSize), blob.type || 'application/octet-stream');
await writer(chunk);
uploaded += chunk.size;
if (typeof onProgress === 'function') {
onProgress({
service: 'dropbox',
uploaded,
total,
percentage: total ? Math.min(100, Math.round((uploaded / total) * 100)) : 0,
});
}
}
await writer(false);
return { status: 'uploaded', service: 'dropbox', filename: name, bytes: uploaded };
}
}

1
core/uploads/index.js Normal file
View File

@@ -0,0 +1 @@
export { CloudUploadCoordinator } from './cloud-storage.js';

View File

@@ -1,127 +1,132 @@
#devices {
max-width: 80%;
width: fit-content;
margin: 0 auto;
}
h1 {
font-size: 1.5em;
padding:10px;
background-color:#457b9d;
color:white;
border-bottom: 2px solid #3b6a87;
}
.device {
display: flex;
flex-direction: column;
margin: 10px 0px;
font-size: 1rem;
padding: 10px;
position: relative;
user-select: none;
background: #d0d0d0;
border-radius: 4px;
}
.device.selected {
background-color: #3ea03c;
}
.device.selected::before {
content: "\f00c";
font-family: "Line Awesome Free";
font-weight: 900;
position: absolute;
top: 10px;
right: 10px;
}
.device:hover {
cursor: pointer;
}
.device-name{
font-weight: bold;
margin-bottom: 5px;
}
.device-id {
}
.card {
margin: 10px;
}
.card > div {
padding: 10px;
}
.notice {
background-color: #fff18c;
margin: 10px;
padding: 20px 20px;
font-weight: bold;
font-size: 1.2em;
text-align: center;
line-height: 1.4em;
}
.notice a {
color: #457b9d;
}
@media only screen
and (min-device-width: 375px)
and (max-device-width: 812px)
and (orientation: portrait) {
#devices {
width: 100%;
max-width: 100%;
}
.device-id {
text-overflow: ellipsis;
overflow: hidden;
}
}
#sharedDevices {
position: fixed;
bottom: 20px;
width: 80%;
left: 10%;
color: white;
overflow-wrap: anywhere;
background: #2c3754;
padding: 20px;
box-shadow: 0px 0px 10px 5px #00000047;
border: 1px solid #333c52;
}
#sharedDevices span {
display: block;
margin-bottom: 10px;
}
#sharedDevices input {
width: 100%;
padding: 5px;
}
span#close {
position: absolute;
top: -10px;
right: -10px;
display: block;
width: 20px;
height: 20px;
background: #457b9d;
text-align: center;
border-radius: 20px;
line-height: 20px;
font-size: 20px;
cursor: pointer;
body {
position:inherit;
height:unset;
padding-bottom:100px;
}
#devices {
max-width: 80%;
width: fit-content;
margin: 0 auto;
}
h1 {
font-size: 1.5em;
padding:10px;
background-color:#457b9d;
color:white;
border-bottom: 2px solid #3b6a87;
}
.device {
display: flex;
flex-direction: column;
margin: 10px 0px;
font-size: 1rem;
padding: 10px;
position: relative;
background: #d0d0d0;
border-radius: 4px;
}
.device.selected {
background-color: #3ea03c;
}
.device.selected::before {
content: "\f00c";
font-family: "Line Awesome Free";
font-weight: 900;
position: absolute;
top: 10px;
right: 10px;
}
.device:hover {
cursor: pointer;
}
.device-name{
font-weight: bold;
margin-bottom: 5px;
}
.device-id {
}
.card {
margin: 10px;
}
.card > div {
padding: 10px;
}
.notice {
background-color: #fff18c;
margin: 10px;
padding: 20px 20px;
font-weight: bold;
font-size: 1.2em;
text-align: center;
line-height: 1.4em;
}
.notice a {
color: #457b9d;
}
@media only screen
and (min-device-width: 375px)
and (max-device-width: 812px)
and (orientation: portrait) {
#devices {
width: 100%;
max-width: 100%;
}
.device-id {
text-overflow: ellipsis;
overflow: hidden;
}
}
#sharedDevices {
position: fixed;
bottom: 20px;
width: 80%;
left: 10%;
color: white;
overflow-wrap: anywhere;
background: #2c3754;
padding: 20px;
box-shadow: 0px 0px 10px 5px #00000047;
border: 1px solid #333c52;
}
#sharedDevices span {
display: block;
margin-bottom: 10px;
}
#sharedDevices input {
width: 100%;
padding: 5px;
}
span#close {
position: absolute;
top: -10px;
right: -10px;
display: block;
width: 20px;
height: 20px;
background: #457b9d;
text-align: center;
border-radius: 20px;
line-height: 20px;
font-size: 20px;
cursor: pointer;
}

View File

@@ -1,12 +1,12 @@
<html>
<head>
<link rel="stylesheet" href="./lineawesome/css/line-awesome.min.css" />
<link rel="stylesheet" href="./main.css?ver=11" />
<link rel="stylesheet" href="./devices.css?ver=1" />
<link rel="stylesheet" href="./main.css" />
<link rel="stylesheet" href="./devices.css?ver=2" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf8" />
</head>
<body>
<body style="position:static" >
<div id="header">
<a
id="logoname"
@@ -14,7 +14,7 @@
style="text-decoration: none; color: white; margin: 2px"
>
<span data-translate="logo-header">
<font id="qos">O</font>BS.Ninja
<font id="qos">V</font>DO.Ninja
</span>
</a>
</div>
@@ -28,6 +28,7 @@
<div class="notice">
Check for browser and camera capabilities <a href="/supports">here</a>.
</div>
<div class="card">
<h1>🎤 Audio Inputs</h1>
<div id="audioInputs"></div>
@@ -40,13 +41,23 @@
<h1>🔉 Audio Outputs</h1>
<div id="audioOutputs"></div>
</div>
<div class="notice info-box">
<h3>🔑 Important Device Information</h3>
<ul>
<li><strong>Device IDs are security-scoped</strong> to specific origins/domains and browsers. They will appear different across domains and after clearing browser data for security reasons.</li>
<li><strong>Speaker/Output selection</strong> requires granting microphone permissions first. Some browsers may require additional permissions.</li>
<li>For iframes, add <code>allow="microphone *; camera *"</code> to enable device access.</li>
<li><strong>Browser compatibility:</strong> Firefox does not support audio output selection via <code>sinkId</code>. Safari added partial support in recent versions.</li>
<li>If device labels appear as "Default" or are missing, you need to grant media permissions before they'll become visible.</li>
</ul>
</div>
</div>
<div id="sharedDevices" style="display: none">
<span>Click to copy. Use this URL to preset audio/video devices.</span>
<span id="close" onclick="this.parentNode.style.display='none'">×</span>
<input id="devicesUrl" value="" />
</div>
<script>
const list = [];
const url = new URL(document.location.origin);
@@ -206,22 +217,84 @@
document.execCommand("copy");
};
navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
devices.forEach((device) => {
console.log(
`${device.kind}: ${device.label} id = ${device.deviceId}`
);
list.push(device);
});
prettyPrint(devices.filter(isAudioInput), "audioInputs");
prettyPrint(devices.filter(isAudioOutput), "audioOutputs");
prettyPrint(devices.filter(isVideoInput), "videoInputs");
})
.catch((err) => {
console.log(`${err.name}: ${err.message}`);
});
async function requestPermissions() {
try {
// Request temporary audio/video access to get device labels
await navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then(stream => {
// Stop all tracks immediately after getting permission
stream.getTracks().forEach(track => track.stop());
})
.catch(err => {
console.warn("Partial permission denied:", err.name);
// Still try to enumerate even with partial permission
});
} catch (e) {
console.warn("Permission request failed:", e);
}
}
async function enumerateDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
// If we don't have labels, request permissions and try again
if (devices.some(device => !device.label)) {
await requestPermissions();
const devicesWithLabels = await navigator.mediaDevices.enumerateDevices();
devices.forEach((device) => {
console.log(
`${device.kind}: ${device.label} id = ${device.deviceId}`
);
list.push(device);
});
prettyPrint(devicesWithLabels.filter(isAudioInput), "audioInputs");
prettyPrint(devicesWithLabels.filter(isAudioOutput), "audioOutputs");
prettyPrint(devicesWithLabels.filter(isVideoInput), "videoInputs");
} else {
// We already have labels, proceed normally
devices.forEach((device) => {
console.log(
`${device.kind}: ${device.label} id = ${device.deviceId}`
);
list.push(device);
});
prettyPrint(devices.filter(isAudioInput), "audioInputs");
prettyPrint(devices.filter(isAudioOutput), "audioOutputs");
prettyPrint(devices.filter(isVideoInput), "videoInputs");
}
} catch (err) {
console.error(`${err.name}: ${err.message}`);
}
}
enumerateDevices();
</script>
<style>
.info-box {
background-color: #f8f9fa;
border-left: 4px solid #17a2b8;
padding: 10px 15px;
margin-bottom: 20px;
text-align: left;
color: black;;
}
.info-box h3 {
margin-top: 0;
color: #17a2b8;
}
.info-box ul {
margin-bottom: 0;
padding-left: 20px;
color:black;
}
.info-box li {
margin-bottom: 5px;
}
.info-box code {
background-color: #e9ecef;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
</style>
</body>
</html>
</html>

838
director-messenger.html Normal file
View File

@@ -0,0 +1,838 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Director Messenger</title>
<style>
:root {
--bg: #13141a;
--panel: #181b23;
--border: #2b3040;
--muted: #f0c674;
--danger: #e06c75;
--ok: #61c38a;
--text: #e8ecf5;
--subtext: #98a1b3;
--accent: #5aa1f7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
height: 100vh;
width: 100vw;
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
color: var(--text);
background: var(--bg);
scrollbar-color: #303546 #0f1015;
overflow: hidden;
}
.app {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 6px;
height: 100%;
padding: 8px;
overflow: auto;
min-height: 0;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h1 {
margin: 0;
font-size: 16px;
letter-spacing: 0.4px;
}
.status-badges {
display: flex;
gap: 8px;
align-items: center;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--subtext);
font-size: 12px;
}
.badge .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--danger);
box-shadow: 0 0 0 2px rgba(224, 108, 117, 0.18);
}
.badge.connected {
color: var(--ok);
border-color: rgba(97, 195, 138, 0.45);
}
.badge.connected .dot {
background: var(--ok);
box-shadow: 0 0 0 2px rgba(97, 195, 138, 0.2);
}
.controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
align-items: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px;
}
.controls label {
font-size: 12px;
color: var(--subtext);
}
.controls input {
min-width: 0;
padding: 8px 9px;
border-radius: 6px;
border: 1px solid var(--border);
background: #10131c;
color: var(--text);
flex: 0 0 110px;
width: 110px;
max-width: 140px;
}
.controls button {
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: linear-gradient(135deg, #3d7bf1, #295ec7);
color: #f8fbff;
cursor: pointer;
transition: transform 80ms ease, box-shadow 120ms ease;
}
.controls button:active {
transform: translateY(1px);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.main {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
min-height: 0;
overflow-y: auto;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 9px;
display: flex;
flex-direction: column;
min-height: 0;
}
.card h2 {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--subtext);
font-weight: 600;
}
.guest-list {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
}
.guest {
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
background: #121722;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;
}
.guest:hover {
border-color: #3d7bf1;
}
.guest.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(90, 161, 247, 0.35);
}
.guest .name {
font-weight: 600;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.guest .meta {
color: var(--subtext);
font-size: 11px;
}
.status-row {
display: flex;
gap: 6px;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
color: var(--text);
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
}
.pill .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--subtext);
}
.pill.ok .dot {
background: var(--ok);
}
.pill.muted .dot {
background: var(--muted);
}
.pill.off .dot {
background: var(--danger);
}
.composer {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.selected-target {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--subtext);
}
.selected-target strong {
color: var(--text);
}
textarea {
width: 100%;
min-height: 90px;
max-height: 200px;
resize: vertical;
border-radius: 10px;
border: 1px solid var(--border);
background: #0e121b;
color: var(--text);
padding: 10px;
font-size: 13px;
flex: 1 1 0;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.actions .left {
display: flex;
align-items: center;
gap: 10px;
color: var(--subtext);
font-size: 12px;
}
.actions button {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border);
background: linear-gradient(135deg, #4ba3f8, #3175dd);
color: #f8fbff;
cursor: pointer;
transition: transform 80ms ease, box-shadow 120ms ease;
}
.actions button:active {
transform: translateY(1px);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.helper {
font-size: 12px;
color: var(--subtext);
line-height: 1.5;
}
.log {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 10px;
max-height: 100px;
overflow-y: auto;
font-size: 12px;
color: var(--subtext);
scrollbar-width: thin;
}
.log div {
margin-bottom: 4px;
}
.empty {
text-align: center;
padding: 20px;
color: var(--subtext);
font-size: 13px;
border: 1px dashed var(--border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
@media (max-width: 900px) {
.app {
padding: 6px;
}
}
.hidden {
display: none !important;
}
.compact header h1 {
display: none;
}
.compact header {
justify-content: flex-start;
}
/* OBS-like scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0f1015;
}
::-webkit-scrollbar-thumb {
background: #313646;
border-radius: 8px;
border: 2px solid #0f1015;
}
::-webkit-scrollbar-thumb:hover {
background: #454b5d;
}
/* Micro layout for very small docks */
.micro .app {
padding: 4px;
gap: 4px;
}
.micro header h1 {
display: none;
}
.micro .badge {
padding: 3px 6px;
font-size: 10px;
}
.micro .badge span:last-child {
display: none;
}
.micro .controls {
padding: 4px 6px;
gap: 6px;
}
.micro .main {
grid-template-columns: 1fr;
gap: 6px;
}
.micro .card {
padding: 7px;
}
.micro .card h2 {
font-size: 12px;
margin-bottom: 6px;
}
.micro .guest {
padding: 6px;
gap: 6px;
}
.micro .guest .name {
font-size: 13px;
}
.micro .guest .meta {
font-size: 10px;
}
.micro .pill {
font-size: 10px;
padding: 3px 6px;
}
.micro textarea {
min-height: 60px;
max-height: 140px;
font-size: 12px;
}
.micro .actions {
gap: 6px;
}
.micro .actions button {
padding: 8px 10px;
}
.micro .helper {
display: none;
}
.micro .log {
max-height: 60px;
font-size: 11px;
}
</style>
</head>
<body>
<div class="app">
<header>
<h1>Director Messenger</h1>
<div class="status-badges">
<span class="badge" id="connectionBadge"><span class="dot"></span><span id="connectionText">Disconnected</span></span>
<span class="badge" id="updateBadge"><span class="dot"></span><span id="updateText">No data yet</span></span>
</div>
</header>
<div class="controls" id="controlsBar">
<label for="apiInput">API key (&api=)</label>
<input id="apiInput" type="password" autocomplete="off" placeholder="your-api-key" />
<button id="connectBtn">Connect</button>
</div>
<div class="main">
<div class="card">
<h2>Room status</h2>
<div id="guestList" class="guest-list">
<div class="empty">Waiting for details… add &api= to your director link to send updates here.</div>
</div>
</div>
<div class="card">
<h2>Send message</h2>
<div class="composer">
<div class="selected-target">Sending to: <strong id="selectedTarget">Pick a guest</strong></div>
<textarea id="messageInput" placeholder="Type a note for the selected guest. Press Enter+Shift to send."></textarea>
<div class="actions">
<div class="left">
<label><input type="checkbox" id="pinToggle" /> Pin for guest</label>
<span id="statusHint"></span>
</div>
<button id="sendBtn">Send</button>
</div>
<div class="helper">
This page rides the VDO.Ninja API. Dock it in OBS and load with the same <code>?api=</code> value as your director tab. Click a guest to target them, then send.
</div>
</div>
</div>
</div>
<div class="log" style="display:none;" id="log"></div>
</div>
<script>
const urlParams = new URLSearchParams(window.location.search);
const apiField = document.getElementById("apiInput");
const connectBtn = document.getElementById("connectBtn");
const controlsBar = document.getElementById("controlsBar");
const guestList = document.getElementById("guestList");
const messageInput = document.getElementById("messageInput");
const sendBtn = document.getElementById("sendBtn");
const connectionBadge = document.getElementById("connectionBadge");
const connectionText = document.getElementById("connectionText");
const updateBadge = document.getElementById("updateBadge");
const updateText = document.getElementById("updateText");
const selectedTarget = document.getElementById("selectedTarget");
const statusHint = document.getElementById("statusHint");
const pinToggle = document.getElementById("pinToggle");
const logBox = document.getElementById("log");
const STORAGE_KEY = "director_messenger_api";
let socket = null;
let socketGeneration = 0;
let reconnectTimer = null;
let pollTimer = null;
let allowReconnect = true;
let apiKey = urlParams.get("api") || urlParams.get("id") || urlParams.get("wid") || "";
let apiServer = urlParams.get("apiserver") || "wss://api.vdo.ninja:443";
let guests = new Map();
let selectedStream = null;
let lastUpdate = null;
if (!apiKey) {
apiKey = localStorage.getItem(STORAGE_KEY) || "";
}
apiField.value = apiKey;
if (urlParams.get("api") || urlParams.get("id") || urlParams.get("wid")) {
controlsBar.classList.add("hidden"); // hide field when API is supplied via URL for OBS dock use
}
function setBadge(state, text) {
connectionBadge.classList.toggle("connected", state === "connected");
connectionText.textContent = text;
}
function setUpdateBadge(state, text) {
updateBadge.classList.toggle("connected", state === "fresh");
updateText.textContent = text;
}
function logLine(text) {
const row = document.createElement("div");
row.textContent = `${new Date().toLocaleTimeString()}${text}`;
logBox.appendChild(row);
logBox.scrollTop = logBox.scrollHeight;
}
function requestDetails() {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
try {
socket.send(JSON.stringify({ action: "getDetails" }));
} catch (e) {
logLine("Failed to request details.");
}
}
function connect() {
clearTimeout(reconnectTimer);
clearInterval(pollTimer);
allowReconnect = true;
const gen = ++socketGeneration;
apiKey = apiField.value.trim();
if (!apiKey) {
setBadge("disconnected", "Enter an API key");
logLine("Missing API key; nothing to connect.");
return;
}
if (socket) {
try {
socket.onclose = null;
socket.onerror = null;
socket.onopen = null;
socket.onmessage = null;
socket.close();
} catch (e) {}
}
connectBtn.textContent = "Connecting…";
const ws = new WebSocket(apiServer);
socket = ws;
ws._gen = gen;
ws.onopen = () => {
if (ws !== socket || ws._gen !== socketGeneration) return;
setBadge("connected", "Connected");
connectBtn.textContent = "Disconnect";
logLine("API socket connected.");
localStorage.setItem(STORAGE_KEY, apiKey);
try {
ws.send(JSON.stringify({ join: apiKey }));
requestDetails();
pollTimer = setInterval(requestDetails, 15000);
} catch (e) {
logLine("Failed to send join message.");
}
};
ws.onerror = () => {
if (ws !== socket || ws._gen !== socketGeneration) return;
setBadge("disconnected", "Error");
logLine("API socket error; retrying…");
};
ws.onclose = () => {
if (ws !== socket || ws._gen !== socketGeneration) return; // stale close from a superseded socket
setBadge("disconnected", "Disconnected");
connectBtn.textContent = "Connect";
clearInterval(pollTimer);
if (reconnectTimer || !allowReconnect) {
return;
}
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 1500);
};
ws.onmessage = event => {
if (ws !== socket || ws._gen !== socketGeneration) return;
try {
const payload = JSON.parse(event.data);
handlePayload(payload);
} catch (e) {
logLine("Received non-JSON payload.");
}
};
}
function handlePayload(payload) {
let data = payload;
if (payload.msg) data = payload.msg;
if (payload.update) data = payload.update;
if (payload.callback) {
data = payload.callback;
logLine(`Command response: ${JSON.stringify(data)}`);
}
const detailsPayload =
(data.action === "details" && data.value) ||
(data.action === "getDetails" && (data.result || data.value)) ||
data.result ||
data.value;
if (detailsPayload) {
lastUpdate = Date.now();
setUpdateBadge("fresh", "Updated");
renderGuests(detailsPayload);
}
}
function renderGuests(detailMap) {
guests = new Map();
if (detailMap && typeof detailMap === "object") {
Object.keys(detailMap).forEach(streamID => {
const entry = detailMap[streamID];
if (!entry) {
return;
}
if (entry.director || entry.localStream || entry.localstream) {
return;
}
guests.set(streamID, entry);
});
}
guestList.innerHTML = "";
if (!guests.size) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "No guests reported yet.";
guestList.appendChild(empty);
selectedStream = null;
selectedTarget.textContent = "Pick a guest";
return;
}
guests.forEach(entry => {
const ele = document.createElement("div");
ele.className = "guest";
ele.dataset.streamId = entry.streamID;
if (selectedStream === entry.streamID) {
ele.classList.add("active");
}
const left = document.createElement("div");
const name = document.createElement("div");
name.className = "name";
name.textContent = entry.label || entry.streamID;
left.appendChild(name);
const meta = document.createElement("div");
meta.className = "meta";
const parts = [];
if (entry.group && entry.group.length) {
parts.push(`Group: ${Array.isArray(entry.group) ? entry.group.join(", ") : entry.group}`);
}
if (entry.streamID) {
parts.push(entry.streamID);
}
meta.textContent = parts.join(" • ");
left.appendChild(meta);
const statusRow = document.createElement("div");
statusRow.className = "status-row";
const mic = document.createElement("span");
mic.className = "pill " + (entry.muted ? "muted" : "ok");
mic.innerHTML = `<span class="dot"></span>${entry.muted ? "Mic muted" : "Mic live"}`;
const cam = document.createElement("span");
cam.className = "pill " + (entry.videoMuted ? "muted" : "ok");
cam.innerHTML = `<span class="dot"></span>${entry.videoMuted ? "Video off" : "Video on"}`;
statusRow.appendChild(mic);
statusRow.appendChild(cam);
if (entry.screenSharing) {
const share = document.createElement("span");
share.className = "pill ok";
share.innerHTML = '<span class="dot"></span>Screen';
statusRow.appendChild(share);
}
ele.appendChild(left);
ele.appendChild(statusRow);
ele.addEventListener("click", () => {
selectedStream = entry.streamID;
selectedTarget.textContent = entry.label || entry.streamID;
document.querySelectorAll(".guest").forEach(g => g.classList.remove("active"));
ele.classList.add("active");
});
guestList.appendChild(ele);
});
}
function sendMessage() {
const message = messageInput.value.trim();
if (!socket || socket.readyState !== WebSocket.OPEN) {
logLine("Cannot send; not connected.");
return;
}
if (!selectedStream) {
logLine("Select a guest first.");
return;
}
if (!message) {
return;
}
const action = pinToggle.checked ? "sendPinnedDirectorChat" : "sendDirectorChat";
const payload = { target: selectedStream, action, value: message };
try {
socket.send(JSON.stringify(payload));
logLine(`Sent "${message}" to ${selectedStream}${pinToggle.checked ? " (pinned)" : ""}.`);
messageInput.value = "";
} catch (e) {
logLine("Failed to send message.");
}
}
sendBtn.addEventListener("click", sendMessage);
messageInput.addEventListener("keydown", e => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
function disconnect(manual = false) {
allowReconnect = false;
clearTimeout(reconnectTimer);
reconnectTimer = null;
clearInterval(pollTimer);
pollTimer = null;
if (socket) {
const ws = socket;
socket = null;
try {
ws.onclose = null;
ws.onerror = null;
ws.onopen = null;
ws.onmessage = null;
ws.close();
} catch (e) {}
}
setBadge("disconnected", "Disconnected");
connectBtn.textContent = "Connect";
if (manual) {
logLine("Disconnected.");
}
}
const resizeObserver = new ResizeObserver(entries => {
const width = entries[0].contentRect.width;
document.body.classList.toggle("compact", width < 520);
document.body.classList.toggle("micro", width < 360 || window.innerHeight < 360);
});
resizeObserver.observe(document.body);
connectBtn.addEventListener("click", () => {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
disconnect(true);
} else {
connect();
}
});
if (apiKey) {
connect();
} else {
setBadge("disconnected", "Enter an API key");
}
setInterval(() => {
if (!lastUpdate) {
return;
}
const seconds = Math.round((Date.now() - lastUpdate) / 1000);
setUpdateBadge("fresh", `Updated ${seconds}s ago`);
}, 5000);
</script>
</body>
</html>

151
dock.html
View File

@@ -1,60 +1,60 @@
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<meta content="utf-8" http-equiv="encoding" />
<style>
body {
transform: scale(0.7);
transform-origin: 0 0;
margin:2px;
padding:0;
border:0;
color: #FFF;
background-color: #1F1E1F;
font-family: Arial, Helvetica, sans-serif;
width:300px;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width:1px;
}
body {
transform: scale(0.7);
transform-origin: 0 0;
margin:2px;
padding:0;
border:0;
color: #FFF;
background-color: #2B2E38;
font-family: Arial, Helvetica, sans-serif;
width:300px;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width:1px;
}
#container-links {
z-index:10;
width:100%;
height:100%;
display:none;
}
#container-links {
z-index:10;
width:100%;
height:100%;
display:none;
}
#container-setup {
width:100%;
height:100%;
display:block;
}
.red {
background-color:#FCC;
}
.green {
background-color:#CFC;
}
.task {
cursor:grab;
width:100%;
padding:5px;
border:2px solid black;
margin:0;
}
button{
padding:5px;
transform: scale(1.4);
transform-origin: 0 0;
}
#container-setup {
width:100%;
height:100%;
display:block;
}
.red {
background-color:#FCC;
}
.green {
background-color:#CFC;
}
.task {
cursor:grab;
width:100%;
padding:5px;
border:2px solid black;
margin:0;
}
button{
padding:5px;
transform: scale(1.4);
transform-origin: 0 0;
}
.gone {
position: absolute;
display:inline-block;
left: -9999px;
}
.gone {
position: absolute;
display:inline-block;
left: -9999px;
}
</style>
</head>
@@ -163,7 +163,7 @@ function generateInvite(){
var href = window.location.href;
var dir = href.substring(0, href.lastIndexOf('/')) + "/";
var salt = location.hostname; // "obs.ninja" is the expected default. You will want to change this if hosting dock.html locally.
var salt = location.hostname; // "vdo.ninja" is the expected default. You will want to change this if hosting dock.html locally.
if (getById("invite_password").value.trim().length){
generateHash(getById("invite_password").value.trim().replace(/[\W]+/g,"_")+salt,4).then(function(hash){
@@ -196,31 +196,27 @@ function goBack(){
}
document.addEventListener("dragstart", event => {
var url = event.target.href || event.target.value;
if (!url || !url.startsWith('https://')) return;
if (event.target.dataset.drag!="1"){
return;
}
//event.target.ondragend = function(){event.target.blur();}
var streamId = url.split('view=');
var label = url.split('label=');
var url = event.target.href || event.target.value;
if (!url || !url.startsWith('https://')) return;
if (event.target.dataset.drag !== "1") return;
var streamId = url.split('view=');
var label = url.split('label=');
url += '&layer-name=OBSN';
if (streamId.length>1) url += ': ' + streamId[1].split('&')[0];
if (label.length>1) url += ' - ' + decodeURI(label[1].split('&')[0]);
url += '&layer-width=1920'; // this isn't always 100% correct, as the resolution can fluxuate, but it is probably good enough
url += '&layer-height=1080';
event.dataTransfer.setDragImage(document.querySelector('#dragImage'), 24, 24);
event.dataTransfer.setData("text/uri-list", encodeURI(url));
//event.dataTransfer.setData("url", encodeURI(url));
//warnlog(event);
url += '&layer-name=VDO.Ninja';
if (streamId.length > 1) url += ': ' + streamId[1].split('&')[0];
if (label.length > 1) url += ' - ' + decodeURI(label[1].split('&')[0]);
// Add layer dimensions
url += '&layer-width=1920';
url += '&layer-height=1080';
event.dataTransfer.setDragImage(document.querySelector('#dragImage'), 24, 24);
event.dataTransfer.setData("text/uri-list", encodeURI(url));
// Add this line to set the URL as plain text as well
event.dataTransfer.setData("text/plain", encodeURI(url));
});
</script>
@@ -232,7 +228,7 @@ document.addEventListener("dragstart", event => {
<br /><br />
<input type="checkbox" id="invite_bitrate" /><label for="invite_bitrate"> <span data-translate="unlock-video-bitrate">Unlock Video Bitrate (20mbps)</span></label>
<input type="checkbox" id="invite_bitrate" /><label for="invite_bitrate"> <span data-translate="unlock-video-bitrate">Unlock Video Bitrate (20-Mbps)</span></label>
<br />
<input type="checkbox" id="invite_vp9" onclick="getById('invite_h264').checked=false;" /><label for="invite_vp9"> <span data-translate="force-vp9-video-codec">VP9 Video Codec</span></label>
<br />
@@ -287,7 +283,6 @@ document.addEventListener("dragstart", event => {
<input id="obs-link" class="task red" data-drag="1" onmousedown="copyFunction(this)" onclick="copyFunction(this)" />
<br />
<br />
<i>(links are draggable)</i>
</div>
</div>
<div class="gone" >

513
docs.html Normal file
View File

@@ -0,0 +1,513 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VDO.Ninja | The Swiss Army knife of low-latency live streaming</title>
<meta name="description" content="VDO.Ninja is the Swiss Army knife of low-latency live streaming.">
<meta name="keywords" content="vdo ninja, streaming, OBS overlay, webrtc, av1, performer, browser source, guest, video, low latency, rtmp, content creators, live streaming tools">
<meta name="author" content="VDO.Ninja">
<link rel="icon" type="image/x-icon" href="https://vdo.ninja/icons/favicon.ico">
<link rel="stylesheet" href="styles.css">
<script async defer src="https://buttons.github.io/buttons.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/lib/marked.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked-gfm-heading-id/3.1.3/index.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked-base-url/1.1.3/index.umd.min.js"></script>
<meta name="sourcecode" content="https://github.com/steveseguin/vdo.ninja" />
<meta name="stance-on-war" content="Steve Seguin condemns Russia's brutal invasion of Ukraine 💙💛." />
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<link id="favicon1" rel="icon" type="image/png" sizes="32x32" href="https://vdo.ninja/media/favicon-32x32.png" />
<link id="favicon2" rel="icon" type="image/png" sizes="16x16" href="https://vdo.ninja/media/favicon-16x16.png" />
<link id="favicon3" rel="icon" href="https://vdo.ninja/media/favicon.ico" />
<meta property="og:title" content="VDO.Ninja | Enhance Your Live Streaming">
<meta property="og:description" content="The Swiss Army knife of low-latency live streaming">
<meta property="og:url" content="https://vdo.ninja">
<meta property="og:type" content="website">
<meta name="twitter:title" content="VDO.Ninja | Tools for Live Streamers">
<meta name="twitter:description" content="VDO.Ninja, a powerful free tool for low-latency live streaming. Discover more at VDO.Ninja.">
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap');
body, html {
margin: 0;
padding: 0;
font-family: 'Poppins', sans-serif;
color: #333;
background-color: #e5e5e5;
padding-bottom: 40px;
line-height: 1.6;
overflow-x: hidden;
}
header {
background: #1a1a1a; /* Darker shade for a rich appearance */
color: #fff;
padding: 20px;
text-align: center;
margin-left: 270px;
}
h1 {
margin: 0;
font-size: 2.4em;
}
p {
margin: 10px 0 0;
font-size: 1.2em;
}
#downloads {
text-align: center;
padding: 20px;
}
.download-btn {
background-color: #007bff; /* Brighter shade of blue */
color: white;
border: none;
padding: 15px 32px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
cursor: pointer;
border-radius: 5px;
transition: background-color 0.3s;
}
.download-btn:hover {
background-color: #0056b3; /* Darker blue on hover */
color: white;
}
#video {
width: 100%;
text-align: center;
padding: 20px;
max-width: calc(100% - 40px);
}
iframe {
max-width: 100%;
border: none; /* Remove border for cleaner look */
}
.section {
padding: 20px 20px 40px 20px;
background-color: #fff;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin: 20px;
border-radius: 10px;
}
.faq-item h3 {
margin: 10px 0;
color: #333;
}
.faq-item p {
font-size: 1em;
color: #666;
}
footer {
background-color: #000e;
color: white;
text-align: center;
padding: 7px;
position: fixed;
bottom: 0;
width: 100vw;
}
footer p {
margin: 0;
}
a {
color: #007bff; /* Links color to match buttons */
text-decoration: none;
}
a:hover {
color: #0056b3; /* Darker blue on hover */
text-decoration: underline; /* Underline on hover for better visibility */
}
.github-btn {
background-color: #cecece;
color: black;
border: none;
padding: 12px 20px;
font-size: 16px;
border-radius: 5px;
text-decoration: none;
display: inline-block;
transition: background-color 0.3s;
margin-top: 12px;
}
.github-btn:hover {
background-color: #b0aeae;
}
#github-buttons {
text-align: center;
margin: 20px 0 10px 0;
}
.logo{
max-height: 1em;
position:relative;
top:0.1em;
}
#content {
background-color: #f8f8f8;
padding: 20px;
border-radius: 8px;
border: 1px solid #ddd;
}
#content h1, #content h2, #content h3 {
color: #333;
}
#content pre, #content code {
background-color: #eee;
border-radius: 5px;
padding: 5px;
font-family: 'Courier New', Courier, monospace;
}
#content a {
color: #007BFF;
text-decoration: none;
}
img {
max-width: 100%;
}
.sidebar {
width: 250px;
position: fixed;
left: 0;
top: 0;
bottom: 0;
background-color: #f4f4f4;
overflow-y: auto;
padding: 20px;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
}
.section {
margin-left: 270px; /* Adjusted to make room for sidebar */
padding: 20px;
}
#sidebar {
list-style: none;
padding: 0;
}
#sidebar h1 {
font-size:1.4em;
margin: auto auto;
text-align: center;
}
#sidebar li a {
display: block;
padding: 10px;
text-decoration: none;
color: #333;
border-bottom: 1px solid #ddd;
}
#sidebar li a:hover {
background-color: #ddd;
}
.nested {
display: none;
list-style-type: none; /* Removes bullet points for nested lists */
padding-left: 20px; /* Indent nested lists */
}
#sidebar li a {
display: block;
padding: 8px;
text-decoration: none;
color: #333;
cursor: pointer;
}
#sidebar li a:hover {
background-color: #ddd;
}
.header-link {
color: inherit; /* Makes the link color the same as the text color */
text-decoration: none; /* No underline */
}
.header-link:hover {
text-decoration: underline; /* Optional: underline on hover */
}
.fancy-button, {
border: 1px solid black;
margin: 2px 10px;
padding: 2px 20px;
border-radius: 10px;
background-color: #eee;
}
.oddembed {
margin: 2px 5px;
padding: 2px 10px;
</style>
</head>
<body>
<header id="header">
<h1>VDO.Ninja <img class="logo" src="https://vdo.ninja/media/old_icon.png"></h1>
<p>The Swiss Army knife of low-latency live streaming</p>
<div id="github-buttons">
<a class="github-button" href="https://github.com/steveseguin/vdo.ninja" data-size="large">
<svg viewBox="0 0 16 16" width="16" height="16" class="octicon octicon-mark-github" aria-hidden="true"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path></svg>
<span class="d-none d-sm-inline"> View on GitHub </span>
</a>
<a class="github-button" href="https://github.com/steveseguin/vdo.ninja" data-icon="octicon-star" data-size="large" data-show-count="true" aria-label="Star steveseguin/vdo.ninja on GitHub">Star</a>
<a class="github-button" href="https://github.com/steveseguin/vdo.ninja/fork" data-icon="octicon-repo-forked" data-size="large" data-show-count="true" aria-label="Fork steveseguin/vdo.ninja on GitHub">Fork</a>
<a class="github-button" href="https://github.com/steveseguin/vdo.ninja/subscription" data-icon="octicon-eye" data-size="large" data-show-count="true" aria-label="Watch steveseguin/vdo.ninja on GitHub">Watch</a>
<a class="github-button" href="https://github.com/sponsors/steveseguin" data-icon="octicon-heart" data-size="large" aria-label="Sponsor @steveseguin on GitHub">Sponsor</a>
</div>
</header>
<div id="sidebar" class="sidebar"></div>
<section id="markdown" class="section">
</section>
<footer>
<p>Join our community for free support on <a href="https://discord.vdo.ninja" target="_blank">Discord</a>.</p>
</footer>
<script>
function smoothScroll(target) {
const element = document.getElementById(target);
if (element) {
window.scrollTo({
top: element.offsetTop,
behavior: 'smooth'
});
}
}
function replaceGitbookTemplates(text) {
const embedPattern = /{% embed url="([^"]+)" %}([^{%]+){% endembed %}/g;
const contentRefPattern = /{% content-ref url="([^"]+)" %}([^{%]+){% endcontent-ref %}/g;
const hintRefPattern = /{% hint style="([^"]+)" %}([^{%]+){% endhint %}/g;
// Replace embeds
text = text.replace(embedPattern, (match, url, description) => {
let src;
const urlObj = new URL(url);
const videoId = new URLSearchParams(urlObj.search).get('v');
// Check if it's a YouTube URL and use the video ID if available
if (urlObj.hostname.includes('youtube.com') && videoId) {
src = `https://www.youtube.com/embed/${videoId}`;
var iframeHtml = ` <iframe width="560" height="315" src="${src}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> `;
description = marked.parse(description);
} else if (urlObj.hostname.includes('youtu.be')) {
src = `https://www.youtube.com/embed/${urlObj.pathname.slice(1)}`;
var iframeHtml = ` <iframe width="560" height="315" src="${src}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> `;
description = marked.parse(description);
} else {
src = url; // Use the full URL for other types of embeds
//description = marked.parse(description);
return ` <a class='oddembed' href="${src}">${description.trim()}</a> `;
}
return `<div>${iframeHtml}<br><small><i>${description.trim()}</i></small></div>`;
});
// Replace content references
text = text.replace(contentRefPattern, (match, url, text) => {
return `<span class="fancy-button">${text.trim()}</span>`;
});
// Replace content references
text = text.replace(hintRefPattern, (match, url, text) => {
return `<small class="`+url+`"><i>${text.trim()}</i></small>`;
});
return text;
}
const githubBaseURL = 'https://raw.githubusercontent.com/steveseguin/vdo.ninja/gitbook/';
function updateURL(path) {
if (history.pushState) {
const currentHash = window.location.hash; // Store current hash
const newurl = `${window.location.protocol}//${window.location.host}${window.location.pathname}?file=${encodeURIComponent(path)}${currentHash}`;
window.history.pushState({path: newurl}, '', newurl);
}
}
function makeHeaderLinks() {
document.getElementById('markdown').querySelectorAll('h1[id], h2[id], h3[id], h4[id]').forEach(header => {
if (!header.parentNode.matches('a')) { // Check if the header is not already inside a link
const id = header.getAttribute('id');
header.innerHTML = `<a href="#${id}" class="header-link">${header.innerHTML}</a>`;
}
});
if (window.location.hash) {
document.getElementById(window.location.hash.substring(1)).scrollIntoView();
}
}
function loadMarkdownFromURL() {
(function(w) {
w.URLSearchParams = w.URLSearchParams || function(searchString) {
var self = this;
searchString = searchString.replace("??", "?");
self.searchString = searchString;
self.get = function(name) {
var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(self.searchString);
if (results == null) {
return null;
} else {
return decodeURI(results[1]) || 0;
}
};
};
})(window);
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
var urlParams = new URLSearchParams(urlEdited);
const file = urlParams.get('file');
if (file) {
const filePath = decodeURIComponent(file);
fetchMarkdown(githubBaseURL + filePath);
return true;
}
return false
}
function fetchMarkdown(href, e=false) {
if (href.endsWith('.md') || (href.startsWith(githubBaseURL) && href.endsWith("/"))) {
if (e){
document.getElementById('header').style.display = "none";
window.location.hash = "";
e.preventDefault();
}
if (!href.endsWith(".md") && href.endsWith("/")){
href += "README.md";
}
if (href.startsWith('http')) {
let x = href.split("/");
x.pop();
x = x.join("/")+"/";
marked.use(markedBaseUrl.baseUrl(x));
updateURL((href).replace(githubBaseURL, ''));
fetch(href)
.then(response => response.text())
.then(text => {
let html = replaceGitbookTemplates(text);
html = marked.parse(html);
document.getElementById('markdown').innerHTML = html;
makeHeaderLinks();
})
.catch(error => console.error('Error loading the markdown file:', error));
} else {
let x = href.split("/");
x.pop();
x = x.join("/")+"/";
marked.use(markedBaseUrl.baseUrl(githubBaseURL+x));
updateURL((githubBaseURL + href).replace(githubBaseURL, ''));
fetch(githubBaseURL + href) // Fetch the markdown file from GitHub
.then(response => response.text())
.then(text => {
let html = replaceGitbookTemplates(text);
html = marked.parse(html);
document.getElementById('markdown').innerHTML = html;
makeHeaderLinks();
})
.catch(error => console.error('Error loading the markdown file:', error));
}
} else if (href.startsWith('http')) {
// This is an absolute URL, let the browser handle it normally
window.open(href, '_blank');
if (e){
e.preventDefault();
}
}
}
document.addEventListener("DOMContentLoaded", function() {
marked.use(markedGfmHeadingId.gfmHeadingId({})); // fml
fetch(githubBaseURL+'SUMMARY.md')
.then(response => response.text())
.then(text => {
const html = marked.parse(text);
document.getElementById('sidebar').innerHTML = html; // Insert converted HTML to the DOM
const subMenus = document.querySelectorAll('#sidebar li ul');
subMenus.forEach(menu => {
menu.classList.add('nested');
});
const sidebarMenu = document.querySelector('#sidebar');
sidebarMenu.addEventListener('click', function(e) {
if (e.target && e.target.nodeName === "A") {
const nextUl = e.target.nextElementSibling;
if (nextUl && nextUl.tagName === 'UL') {
// Prevent default if there's a nested UL to toggle
e.preventDefault();
nextUl.style.display = (nextUl.style.display === 'none' || !nextUl.style.display) ? 'block' : 'none';
}
}
});
document.body.addEventListener('click', function(e) {
if (e.target && e.target.nodeName === "A") {
const href = e.target.getAttribute('href');
fetchMarkdown(href,e);
}
});
var saved = loadMarkdownFromURL();
if (!saved){
fetch(githubBaseURL+'README.md')
.then(response => response.text())
.then(text => {
let html = replaceGitbookTemplates(text);
html = marked.parse(html);
document.getElementById('markdown').innerHTML = html;
})
.catch(error => console.error('Error loading the README:', error));
}
})
.catch(error => console.error('Error loading the SUMMARY:', error));
});
</script>
</body>
</html>

296
dropbox-auth.html Normal file
View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dropbox Authorization · VDO.Ninja</title>
<style>
:root {
color-scheme: dark;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #05060a;
color: #f4f8ff;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
main {
max-width: 460px;
background: rgba(15, 17, 28, 0.9);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
padding: 24px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
h1 {
font-size: 1.35rem;
margin: 0 0 8px;
}
p {
margin: 0;
font-size: 0.95rem;
line-height: 1.4;
}
p.status {
margin-top: 12px;
font-size: 0.88rem;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.75;
}
p.status[data-variant="error"] {
color: #ff8a84;
opacity: 1;
}
p.status[data-variant="success"] {
color: #2fe7a3;
opacity: 1;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
background: rgba(255, 255, 255, 0.08);
padding: 2px 4px;
border-radius: 4px;
}
</style>
</head>
<body>
<main>
<h1>Finishing Dropbox link…</h1>
<p>Hold tight while we exchange the authorization code for a long-lived token.</p>
<p id="dropbox-auth-status" class="status">Authorizing…</p>
</main>
<script>
(() => {
const MESSAGE_SOURCE = "vdoninja-dropbox-auth";
const TOKEN_URL = "https://api.dropboxapi.com/oauth2/token";
const TOKEN_STORAGE_KEY = "dropboxOAuthTokens";
const SESSION_KEY = "dropboxOAuthSession";
const REFRESH_SKEW_MS = 120000;
const statusEl = document.getElementById("dropbox-auth-status");
function setStatus(message, variant = "info") {
if (statusEl) {
statusEl.textContent = message;
statusEl.dataset.variant = variant;
}
}
function readSession() {
try {
const raw = localStorage.getItem(SESSION_KEY);
return raw ? JSON.parse(raw) : null;
} catch (error) {
console.error("Failed to read Dropbox auth session", error);
return null;
}
}
function clearSession() {
try {
localStorage.removeItem(SESSION_KEY);
} catch (error) {}
}
function storeTokens(tokens) {
if (!tokens || !tokens.accessToken) {
return;
}
try {
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(tokens));
} catch (error) {
console.error("Failed to persist Dropbox tokens", error);
}
}
function normalizeTokenResponse(response, fallbackRefreshToken) {
if (!response || !response.access_token) {
return null;
}
let expiresIn = 0;
if (response.expires_in) {
const parsed = parseInt(response.expires_in, 10);
if (!isNaN(parsed) && parsed > 0) {
expiresIn = parsed * 1000;
}
}
const expiresAt = expiresIn ? Date.now() + Math.max(0, expiresIn - REFRESH_SKEW_MS) : 0;
return {
accessToken: response.access_token,
refreshToken: response.refresh_token || fallbackRefreshToken || null,
expiresAt,
scope: response.scope || "files.content.write files.metadata.write",
tokenType: response.token_type || "bearer"
};
}
function postResult(payload) {
if (!payload) {
return;
}
try {
const targetOrigin = payload.targetOrigin || window.location.origin;
if (window.opener && typeof window.opener.postMessage === "function") {
window.opener.postMessage(payload, targetOrigin);
}
} catch (error) {
console.error("Failed to post auth result", error);
}
}
function closeSoon() {
setTimeout(() => {
try {
window.close();
} catch (error) {}
}, 1600);
}
async function exchangeCode(sessionData, code) {
const params = new URLSearchParams();
params.set("code", code);
params.set("grant_type", "authorization_code");
params.set("redirect_uri", sessionData.redirectUri);
params.set("client_id", sessionData.clientId);
params.set("code_verifier", sessionData.verifier);
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString()
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(text || "Dropbox token exchange failed.");
}
return response.json();
}
function waitForSessionFromOpener(expectedState) {
if (!window.opener || typeof window.opener.postMessage !== "function") {
return Promise.resolve(null);
}
return new Promise((resolve) => {
let settled = false;
let timeoutId = null;
function handleMessage(event) {
if (!event || !event.data || event.data.source !== MESSAGE_SOURCE || event.data.type !== "session") {
return;
}
window.removeEventListener("message", handleMessage);
if (!settled) {
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(event.data.session || null);
}
}
window.addEventListener("message", handleMessage);
timeoutId = setTimeout(() => {
window.removeEventListener("message", handleMessage);
if (!settled) {
settled = true;
resolve(null);
}
}, 2000);
try {
window.opener.postMessage({ source: MESSAGE_SOURCE, type: "request-session", state: expectedState || null }, "*");
} catch (error) {
window.removeEventListener("message", handleMessage);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (!settled) {
settled = true;
resolve(null);
}
}
});
}
async function ensureAuthSession(expectedState) {
let session = readSession();
if (session) {
return session;
}
const remoteSession = await waitForSessionFromOpener(expectedState);
if (remoteSession) {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(remoteSession));
} catch (error) {}
}
return remoteSession;
}
async function init() {
const query = new URLSearchParams(window.location.search || window.location.hash.replace("?", ""));
const errorParam = query.get("error");
const code = query.get("code");
const state = query.get("state");
const authSession = await ensureAuthSession(state);
if (errorParam) {
const message = errorParam === "access_denied" ? "Access to Dropbox was denied." : `Dropbox returned an error: ${errorParam}`;
setStatus(message, "error");
const targetOrigin = authSession?.origin || window.location.origin;
postResult({ source: MESSAGE_SOURCE, type: "error", message, clearTokens: false, targetOrigin });
clearSession();
closeSoon();
return;
}
if (!code) {
setStatus("No authorization code present in the URL.", "error");
const targetOrigin = authSession?.origin || window.location.origin;
clearSession();
postResult({ source: MESSAGE_SOURCE, type: "error", message: "Missing authorization code.", clearTokens: false, targetOrigin });
return;
}
if (!authSession || !authSession.verifier) {
setStatus("This tab no longer has a pending authorization session.", "error");
const expiredOrigin = authSession?.origin || window.location.origin;
postResult({ source: MESSAGE_SOURCE, type: "error", message: "Authorization session expired.", clearTokens: false, targetOrigin: expiredOrigin });
clearSession();
return;
}
if (authSession.state && state && authSession.state !== state) {
setStatus("State mismatch; refusing to continue.", "error");
postResult({ source: MESSAGE_SOURCE, type: "error", message: "Dropbox state mismatch.", clearTokens: false, targetOrigin: authSession.origin || window.location.origin });
clearSession();
return;
}
setStatus("Requesting long-lived Dropbox token…", "info");
try {
const rawTokens = await exchangeCode(authSession, code);
const tokens = normalizeTokenResponse(rawTokens, authSession.refreshToken);
if (!tokens) {
throw new Error("Dropbox returned an invalid token payload.");
}
storeTokens(tokens);
setStatus("Dropbox linked successfully. You can close this window.", "success");
postResult({ source: MESSAGE_SOURCE, type: "tokens", tokens, targetOrigin: authSession.origin || window.location.origin });
clearSession();
closeSoon();
} catch (error) {
console.error("Dropbox OAuth failed", error);
setStatus(error?.message || "Unable to complete Dropbox authorization.", "error");
postResult({ source: MESSAGE_SOURCE, type: "error", message: error?.message || "Dropbox authorization failed.", clearTokens: true, targetOrigin: authSession.origin || window.location.origin });
clearSession();
}
}
init();
})();
</script>
</body>
</html>

View File

@@ -155,9 +155,6 @@
}
}
#messageDiv {
font-size: .7em;
color: #DDD;
@@ -272,12 +269,49 @@
flex-wrap: nowrap;
flex-grow: 1;
}
#version{
margin: 0 auto;
font-size: 30%;
display: inline-block;
color: #000A;
right: 3px;
position: absolute;
bottom: 0;
}
#lastUrls {
/* Keep your original styles for the select element */
font-size: calc(16px + 0.3vw);
width: 730px;
height: 100%;
flex: 20;
border-radius: 10px;
padding: 1em;
background: #101520;
color: white;
cursor: pointer;
}
/* These styles target the dropdown list in Webkit browsers */
#lastUrls::-webkit-listbox {
max-height: 200px !important;
}
/* For Firefox */
#lastUrls {
scrollbar-width: thin;
scrollbar-color: #384861 #182031;
}
/* This will help force the height of the dropdown menu in many browsers */
@supports (-moz-appearance:none) {
#lastUrls {
overflow: -moz-scrollbars-vertical;
}
}
</style>
</head>
<body >
<div id="header" style="-webkit-app-region: drag;color:#6f6f6f;font-size:40px; line-height: 40px; padding: 5px 10px; letter-spacing: 3; font-weight: bold;">Electron Capture</div>
<body>
<div id="header" style="-webkit-app-region: drag; color:#6f6f6f;font-size:20px; line-height: 20px; padding: 5px 10px; letter-spacing: 3; font-weight: bold;">Electron Capture</div>
<div class="container" >
<div id='warning4mac' style="display:none;"> ✨ Great News! OBS v26.1.2 <a href="https://github.com/obsproject/obs-browser/issues/209#issuecomment-748683083">now supports</a> VDO.Ninja without needing the Electron Capture app! 🥳</div>
@@ -306,8 +340,7 @@
</div>
</div>
<div id="version"></div>
<script>
/*
* Copyright (c) 2020 Steve Seguin. All Rights Reserved.
@@ -317,28 +350,96 @@
* tree. Alternative licencing options can be made available on request.
*
*/
var lastUrls = JSON.parse(localStorage.getItem('lastUrls'));
if (lastUrls != undefined) {
document.querySelector("#changeText").value = lastUrls[0];
if (lastUrls.length>0){
lastUrls.forEach((url)=>{
var o = document.createElement('option');
o.value = url;
o.text = url;
document.querySelector("#lastUrls").appendChild(o);
})
} else {
document.querySelector("#history").style.display="none";
}
} else {
document.querySelector("#history").style.display="none";
}
function setUrl(){
document.querySelector("#changeText").value = document.querySelector("#lastUrls").value;
gohere();
}
function createCustomScrollableDropdown() {
const originalSelect = document.querySelector('#lastUrls');
if (!originalSelect) return;
// Create custom dropdown container
const dropdownContainer = document.createElement('div');
dropdownContainer.id = 'custom-lastUrls-container';
dropdownContainer.style.position = 'relative';
dropdownContainer.style.width = '730px';
dropdownContainer.style.flex = '20';
// Create the dropdown header (what shows when closed)
const dropdownHeader = document.createElement('div');
dropdownHeader.id = 'custom-lastUrls-header';
dropdownHeader.style.fontSize = 'calc(16px + 0.3vw)';
dropdownHeader.style.padding = '1em';
dropdownHeader.style.borderRadius = '10px';
dropdownHeader.style.background = '#FFF';
dropdownHeader.style.color = '#000';
dropdownHeader.style.cursor = 'pointer';
dropdownHeader.textContent = originalSelect.options.length > 0 ?
originalSelect.options[0].text : 'History';
// Create the dropdown list (what shows when opened)
const dropdownList = document.createElement('div');
dropdownList.id = 'custom-lastUrls-list';
dropdownList.style.display = 'none';
dropdownList.style.position = 'absolute';
dropdownList.style.width = '100%';
dropdownList.style.maxHeight = '200px';
dropdownList.style.overflowY = 'auto';
dropdownList.style.background = '#DDD';
dropdownList.style.borderRadius = '10px';
dropdownList.style.zIndex = '100';
// Add options to the dropdown list
Array.from(originalSelect.options).forEach((option, index) => {
const item = document.createElement('div');
item.className = 'custom-lastUrls-item';
item.style.padding = '0.5em 1em';
item.style.cursor = 'pointer';
item.textContent = option.text;
item.dataset.value = option.value;
if (index % 2 === 1) {
item.style.backgroundColor = '#FFF';
}
item.addEventListener('click', function() {
dropdownHeader.textContent = this.textContent;
dropdownList.style.display = 'none';
// Update the original select value and trigger its change event
originalSelect.value = this.dataset.value;
const event = new Event('change');
originalSelect.dispatchEvent(event);
});
dropdownList.appendChild(item);
});
// Toggle dropdown on header click
dropdownHeader.addEventListener('click', function() {
const isDisplayed = dropdownList.style.display === 'block';
dropdownList.style.display = isDisplayed ? 'none' : 'block';
});
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!dropdownContainer.contains(e.target)) {
dropdownList.style.display = 'none';
}
});
// Add everything to the container
dropdownContainer.appendChild(dropdownHeader);
dropdownContainer.appendChild(dropdownList);
// Replace the original select with our custom dropdown
originalSelect.style.display = 'none';
originalSelect.parentNode.insertBefore(dropdownContainer, originalSelect);
}
function resetHistory(){
localStorage.clear();
document.querySelector('#lastUrls').innerHTML = '';
@@ -364,10 +465,11 @@ function resetHistory(){
var urlParams = new URLSearchParams(window.location.search);
if ((location.hostname.toLowerCase() == "vdo.ninja") || (location.hostname.toLowerCase() == "obs.ninja")){
if (location.hostname.toLowerCase() == "vdo.ninja"){
try {
if (navigator.userAgent.toLowerCase().indexOf(' electron/') > -1) {
function compareVersions(version){
document.getElementById("version").innerHTML = "Current version: "+version;
version = version.split(".");
fetch('https://api.github.com/repos/steveseguin/electroncapture/releases/latest')
.then(response => response.json())
@@ -375,13 +477,13 @@ if ((location.hostname.toLowerCase() == "vdo.ninja") || (location.hostname.toLow
console.log("recentVersion: "+data.tag_name);
var recentVersion = data.tag_name.split(".");
var ood = false;
if (recentVersion[0]>version[0]){
if (parseInt(recentVersion[0])>parseInt(version[0])){
ood = true;
} else if (recentVersion[0]==version[0]) {
if (recentVersion[1]>version[1]){
} else if (parseInt(recentVersion[0])==parseInt(version[0])) {
if (parseInt(recentVersion[1])>parseInt(version[1])){
ood = true;
} else if (recentVersion[1]==version[1]) {
if (recentVersion[2]>version[2]){
} else if (parseInt(recentVersion[1])==parseInt(version[1])) {
if (parseInt(recentVersion[2])>parseInt(version[2])){
ood = true;
}
}
@@ -392,19 +494,18 @@ if ((location.hostname.toLowerCase() == "vdo.ninja") || (location.hostname.toLow
}
}).catch(console.error);
}
if (urlParams.has('version')){
var ver = urlParams.get('version');
if (urlParams.has('version') || urlParams.has('ver')){
var ver = urlParams.get('version') || urlParams.get('ver') || false;
console.log("version: "+ver);
compareVersions(ver);
if (ver){
compareVersions(ver);
}
} else{
var checkVersion = setTimeout(function(){ // pre 1.5.2
compareVersions("0.0.0");
},500);
document.getElementById("version").innerHTML = "Elevate app privilleges to see current version";
try{
const ipcRenderer = require('electron').ipcRenderer;
console.log("ELECTRON DETECTED");
ipcRenderer.on('appVersion', function(event, version) {
clearTimeout(checkVersion);
console.log("version: "+version);
compareVersions(version);
})
@@ -442,13 +543,22 @@ function getPermssions(e=null){
});
listed=true;
audioOutputSelect.focus();
}).catch(function(){
document.getElementById("messageDiv").innerHTML = "Failed to list available output devices\n\nPlease ensure you allowed the microphone permissions.";
}).catch(function(err){
var errorMessage = "Failed to list available audio devices\n\n";
if (err && err.name === "NotFoundError") {
errorMessage += "No microphone detected. Please connect a microphone and refresh.";
} else if (err && err.name === "NotAllowedError") {
errorMessage += "Microphone permission denied. Please allow microphone access.";
} else if (err && err.name === "NotReadableError") {
errorMessage += "Microphone is in use by another application.";
} else {
errorMessage += "Please ensure you have a microphone connected and allowed permissions.";
}
document.getElementById("messageDiv").innerHTML = errorMessage;
document.getElementById("messageDiv").style.display="block";
setTimeout(function(){document.getElementById("messageDiv").style.opacity="1.0";},0);
});
});
}
function gotDevices(deviceInfos) {
@@ -473,10 +583,203 @@ function enterPressed(event, callback){
}
}
function checkForSpecialVideoDevices() {
if (navigator.userAgent.toLowerCase().indexOf(' electron/') > -1) {
navigator.mediaDevices.enumerateDevices().then(devices => {
const specialDevices = [
"OBS Virtual Camera",
"Streamlabs Desktop Virtual Webcam",
"vMix Video",
"Blackmagic",
"NDI Video"
];
let detectedDevice = null;
for (const priorityDevice of specialDevices) {
for (const device of devices) {
if (device.kind === 'videoinput' && device.label.includes(priorityDevice)) {
detectedDevice = device;
break;
}
}
if (detectedDevice) break;
}
if (detectedDevice) {
createSpecialDeviceLink(detectedDevice.label);
}
}).catch(console.error);
}
}
function createSpecialDeviceLink(deviceLabel) {
const normalizedLabel = normalizeDeviceLabel(deviceLabel);
const link = document.createElement('a');
link.href = `./?vd=${normalizedLabel}&fullscreen&cleanoutput&webcam&autostart&push=JNVWFzC&bypass&ad=0&nohistory`;
link.textContent = `${deviceLabel} detected - Click to full-window it`;
link.style.position = 'fixed';
link.style.bottom = '10px';
link.style.left = '50%';
link.style.transform = 'translateX(-50%)';
link.style.backgroundColor = 'rgba(50, 50, 50, 0.7)';
link.style.color = '#e0e0e0';
link.style.padding = '8px 12px';
link.style.borderRadius = '20px';
link.style.fontSize = '14px';
link.style.textDecoration = 'none';
link.style.opacity = '0';
link.style.transition = 'opacity 2s ease-in-out';
link.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
link.style.fontFamily = 'Arial, sans-serif';
link.title = "Make this video device fully fill the window, making it perfect for screen capture.";
document.body.appendChild(link);
setTimeout(() => link.style.opacity = '1', 100);
// Add hover effect
link.onmouseenter = () => {
link.style.backgroundColor = 'rgba(70, 70, 70, 0.9)';
};
link.onmouseleave = () => {
link.style.backgroundColor = 'rgba(50, 50, 50, 0.7)';
};
}
function checkForAsioDevices() {
if (navigator.userAgent.toLowerCase().indexOf(' electron/') > -1) {
try {
// Try async first (works in sandbox mode)
if (window.electronApi && window.electronApi.isAsioAvailableAsync) {
window.electronApi.isAsioAvailableAsync().then(function(available) {
if (!available) return;
window.electronApi.getAsioDevicesAsync().then(function(asioDevices) {
if (asioDevices && asioDevices.length > 0) {
createAsioNotice(asioDevices);
}
}).catch(function(e) { console.warn("ASIO devices check failed:", e); });
}).catch(function(e) { console.warn("ASIO availability check failed:", e); });
}
// Fallback to sync (works when --node flag used)
else if (window.electronApi && window.electronApi.isAsioAvailable && window.electronApi.isAsioAvailable()) {
var asioDevices = window.electronApi.getAsioDevices();
if (asioDevices && asioDevices.length > 0) {
createAsioNotice(asioDevices);
}
}
} catch (e) {
console.warn("ASIO detection check failed:", e);
}
}
}
function createAsioNotice(asioDevices) {
var noticeText = asioDevices.length === 1
? "ASIO: " + asioDevices[0].name
: "ASIO: " + asioDevices.length + " devices";
var notice = document.createElement('div');
notice.id = 'asioNotice';
notice.style.position = 'fixed';
notice.style.bottom = '45px';
notice.style.left = '50%';
notice.style.transform = 'translateX(-50%)';
notice.style.backgroundColor = 'rgba(0, 40, 0, 0.8)';
notice.style.color = '#6f6';
notice.style.padding = '8px 12px';
notice.style.borderRadius = '20px';
notice.style.fontSize = '14px';
notice.style.textDecoration = 'none';
notice.style.opacity = '0';
notice.style.transition = 'opacity 2s ease-in-out';
notice.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
notice.style.fontFamily = 'Arial, sans-serif';
notice.title = "Professional low-latency audio input available via ASIO";
var textSpan = document.createElement('span');
textSpan.textContent = noticeText;
var linkSpan = document.createElement('span');
linkSpan.textContent = ' - ';
var link = document.createElement('a');
link.href = 'https://github.com/steveseguin/electroncapture#asio-audio-capture-windows-only';
link.target = '_blank';
link.textContent = 'Low-latency pro audio available';
link.style.color = '#0ff';
notice.appendChild(textSpan);
notice.appendChild(linkSpan);
notice.appendChild(link);
document.body.appendChild(notice);
setTimeout(() => notice.style.opacity = '1', 100);
console.log("ASIO devices available:", asioDevices.map(function(d) { return d.name; }));
}
function normalizeDeviceLabel(deviceName) {
return String(deviceName).replace(/[\W]+/g, "_").toLowerCase();
}
function getPermssions(e=null){
if (listed==true){
return;
}
if (e!==null){
e.currentTarget.blur();
}
navigator.mediaDevices.getUserMedia({audio: true, video: false}).then((stream)=>{
navigator.mediaDevices.enumerateDevices().then(gotDevices).catch(console.error);
stream.getTracks().forEach(track => {
track.stop();
});
listed=true;
audioOutputSelect.focus();
}).catch(function(err){
var errorMessage = "Failed to list available audio devices\n\n";
if (err && err.name === "NotFoundError") {
errorMessage += "No microphone detected. Please connect a microphone and refresh.";
} else if (err && err.name === "NotAllowedError") {
errorMessage += "Microphone permission denied. Please allow microphone access.";
} else if (err && err.name === "NotReadableError") {
errorMessage += "Microphone is in use by another application.";
} else {
errorMessage += "Please ensure you have a microphone connected and allowed permissions.";
}
document.getElementById("messageDiv").innerHTML = errorMessage;
document.getElementById("messageDiv").style.display="block";
setTimeout(function(){document.getElementById("messageDiv").style.opacity="1.0";},0);
});
}
function preloadCSS(url) {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'style';
link.href = url;
document.head.appendChild(link);
}
function lazyPreloadCSS() {
const cssFiles = [
'./main.css'
];
cssFiles.forEach(preloadCSS);
}
var isMobile = false;
if( /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)){ // does not detect iPad Pros.
isMobile=true; // if iOS, default to H264? meh. let's not.
} else {
// Near the end of your script, replace or add:
document.addEventListener('DOMContentLoaded', () => {
getPermssions();
checkForSpecialVideoDevices();
checkForAsioDevices();
setTimeout(lazyPreloadCSS, 2000);
});
}
// Windows can show the cursor, since it captures in a different way.
//if (navigator.platform.indexOf("Win") != -1){
@@ -540,7 +843,7 @@ function addUrlToHistory(url){
}
if ( lastUrls[0] != url ) {
lastUrls.unshift(url);
if (lastUrls.length == 6) {
if (lastUrls.length == 100) {
lastUrls.pop();
}
}
@@ -562,7 +865,7 @@ function modURL(){
} else if (url.startsWith("https://")){
// pass
} else if (url.startsWith("file:")){
// pass
alert("Warning:\n\nFor security purposes, local files need to be loaded via the command-line or via the right-click context menu -> Edit URL.\n\nThis is supported in Electron Capture 2.15.2 and newer.");
} else {
url = "https://"+url;
}
@@ -580,7 +883,26 @@ function gohere(){
}
window.location = url;
};
var lastUrls = JSON.parse(localStorage.getItem('lastUrls'));
if (lastUrls != undefined) {
document.querySelector("#changeText").value = lastUrls[0];
if (lastUrls.length > 0) {
lastUrls.forEach((url) => {
var o = document.createElement('option');
o.value = url;
o.text = url;
document.querySelector("#lastUrls").appendChild(o);
});
// Create custom dropdown after populating
createCustomScrollableDropdown();
} else {
document.querySelector("#history").style.display = "none";
}
} else {
document.querySelector("#history").style.display = "none";
}
getPermssions();
</script>
</body>
</html>
</html>

394
electroncapture.html Normal file
View File

@@ -0,0 +1,394 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ElectronCapture Deep Link Generator</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.js"></script>
<style>
/* Modern Grey Theme */
:root {
--primary-color: #2A2A2A;
--secondary-color: #404040;
--accent-color: #6366F1;
--accent-hover: #4F46E5;
--success-color: #22C55E;
--success-hover: #16A34A;
--text-primary: #E5E7EB;
--text-secondary: #9CA3AF;
--background-primary: #1A1A1A;
--background-secondary: #262626;
--background-tertiary: #333333;
--border-color: #404040;
}
body {
background-color: var(--background-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.5;
min-height: 100vh;
max-width: 640px;
margin: auto auto;
}
.max-w-4xl {
background-color: var(--background-secondary);
border: 1px solid var(--border-color);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
h1 {
color: var(--text-primary);
font-weight: 700;
border-bottom: 2px solid var(--accent-color);
padding-bottom: 0.5rem;
margin-bottom: 2rem;
}
label {
color: var(--text-secondary);
font-weight: 500;
}
input[type="url"],
input[type="number"],
input[type="text"] {
background-color: var(--background-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
transition: all 0.2s ease-in-out;
}
input[type="number"]{
width: 50px;
}
input[type="url"]{
width: 98%;
padding: 5px;
margin: 5px 0;
font-size: 1.1em;
}
input[type="url"]:focus,
input[type="number"]:focus,
input[type="text"]:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
background-color: var(--background-secondary);
}
input[type="checkbox"] {
accent-color: var(--accent-color);
width: 1.2rem;
height: 1.2rem;
border-radius: 4px;
cursor: pointer;
}
.bg-gray-50 {
background-color: var(--background-tertiary);
border: 1px solid var(--border-color);
}
#generatedLink {
background-color: var(--background-secondary);
color: var(--text-primary);
font-family: monospace;
font-size: 0.9rem;
padding: 0.75rem;
border: 1px solid var(--border-color);
}
/* Button Styles */
#copyBtn, #testLink {
padding: 0.5rem 1rem;
font-weight: 500;
transition: all 0.2s ease-in-out;
border: none;
cursor: pointer;
}
#copyBtn {
background-color: var(--accent-color);
color: white;
}
#copyBtn:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
}
#testLink {
background-color: var(--success-color);
color: white;
}
#testLink:hover {
background-color: var(--success-hover);
transform: translateY(-1px);
}
/* Form Layout Improvements */
.space-y-6 > * {
margin-bottom: 1.5rem;
}
.grid {
gap: 1.5rem;
}
/* Modern Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--secondary-color);
}
/* Responsive Improvements */
@media (max-width: 768px) {
body {
padding: 1rem;
}
.max-w-4xl {
padding: 1.5rem;
}
.grid {
gap: 1rem;
}
#generatedLink {
font-size: 0.8rem;
}
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.max-w-4xl {
animation: fadeIn 0.3s ease-out;
}
/* Focus Outline */
:focus {
outline: none;
}
/* Copy Button Success State */
#copyBtn.bg-green-500 {
background-color: var(--success-color);
}
#copyBtn.bg-green-500:hover {
background-color: var(--success-hover);
}
</style>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-4xl mx-auto bg-white rounded-lg shadow-lg p-6" style="padding:20px;">
<h1 class="text-3xl font-bold mb-6 text-gray-800">ElectronCapture Deep Link Generator</h1>
<form id="linkForm" class="space-y-6">
<!-- URL Input -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Target URL (https://...)
</label>
<input type="url" id="url" required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://example.com">
</div>
<!-- Window Size -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Window Width (pixels)
</label>
<input type="number" id="width"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="1280">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Window Height (pixels)
</label>
<input type="number" id="height"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="720">
</div>
</div>
<!-- Window Position -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
X Position (pixels)
</label>
<input type="number" id="x"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Y Position (pixels)
</label>
<input type="number" id="y"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="100">
</div>
</div>
<!-- Window Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Window Title
</label>
<input type="text" id="title"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="My Window">
</div>
<!-- Checkboxes -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex items-center">
<input type="checkbox" id="pin" class="h-4 w-4 text-blue-600 rounded">
<label class="ml-2 text-sm text-gray-700">
Always on Top
</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="full" class="h-4 w-4 text-blue-600 rounded">
<label class="ml-2 text-sm text-gray-700">
Start Fullscreen
</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="min" class="h-4 w-4 text-blue-600 rounded">
<label class="ml-2 text-sm text-gray-700">
Start Minimized
</label>
</div>
</div>
</form>
<!-- Generated Link Section -->
<div class="mt-8 p-4 bg-gray-50 rounded-lg">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-700">
Generated Deep Link:
</label>
</div>
<div class="flex space-x-4">
<input type="text" id="generatedLink" style="width: calc(100% - 1.5rem - 1px);" readonly
class="w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm text-gray-700"
value="electroncapture://">
<div class="flex gap-2"> <!-- Added container for buttons -->
<button id="copyBtn" class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm">
Copy Link
</button>
<button id="testLink" class="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 text-sm">
Test Link
</button>
</div>
</div>
</div>
</div>
<br />
<small style="color:#888;">Note: You can download <a href="https://github.com/steveseguin/electroncapture/releases/" style="color:#888;" target="_blank"> ElectronCapture here</a> if needed.</small>
<script>
function generateLink() {
const url = document.getElementById('url').value.trim();
if (!url) return;
// Remove 'https://' if present
const cleanUrl = url.replace(/^https?:\/\//, '');
let params = new URLSearchParams();
// Add parameters only if they have values
const width = document.getElementById('width').value;
const height = document.getElementById('height').value;
const x = document.getElementById('x').value;
const y = document.getElementById('y').value;
const title = document.getElementById('title').value;
const pin = document.getElementById('pin').checked;
const full = document.getElementById('full').checked;
const min = document.getElementById('min').checked;
if (width) params.append('w', width);
if (height) params.append('h', height);
if (x) params.append('x', x);
if (y) params.append('y', y);
if (title) params.append('title', title);
if (pin) params.append('pin', 'true');
if (full) params.append('full', 'true');
if (min) params.append('min', 'true');
const paramsString = params.toString();
const deepLink = `electroncapture://${cleanUrl}${paramsString ? '?' + paramsString : ''}`;
document.getElementById('generatedLink').value = deepLink;
document.getElementById('testLink').href = deepLink;
}
// Add event listeners to all form elements
document.querySelectorAll('#linkForm input').forEach(input => {
input.addEventListener('input', generateLink);
input.addEventListener('change', generateLink);
});
document.getElementById('testLink').addEventListener('click', () => {
window.location.href = document.getElementById('generatedLink').value;
});
// Copy button functionality
document.getElementById('copyBtn').addEventListener('click', () => {
const linkInput = document.getElementById('generatedLink');
linkInput.select();
document.execCommand('copy');
// Visual feedback
const copyBtn = document.getElementById('copyBtn');
const originalText = copyBtn.textContent;
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('bg-green-500');
copyBtn.classList.remove('bg-blue-500');
setTimeout(() => {
copyBtn.textContent = originalText;
copyBtn.classList.remove('bg-green-500');
copyBtn.classList.add('bg-blue-500');
}, 2000);
});
</script>
</body>
</html>

View File

@@ -1,131 +0,0 @@
<html>
<head>
<title>IFRAME Example</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color: #0000;
}
iframe {
border:0;
margin:0;
padding:0;
display:block;
width:100%;
height:90%
}
#viewlink {
width:400px;
}
#container {
display:block;
padding:0px;
}
input{
padding:5px;
margin:5px;
}
button{
padding:5px;
margin:5px;
}
</style>
<script>
function loadIframe(){
document.getElementById("container").innerHTML = "";
var iframe = document.createElement("iframe");
var iframeContainer = document.createElement("div");
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
var iframesrc = "https://vdo.ninja/?transparent&cleanoutput&bitrate=200&manual&noaudio&view=";
var listOfStreamIDs = [
"1234_pov",
"2345_pov",
"3456_pov",
"4567_pov",
"5678_pov"
];
var button = document.createElement("button");
button.innerHTML = "List connected StreamIDs";
button.onclick = function(){iframe.contentWindow.postMessage({"getStreamIDs":true}, '*');};
iframeContainer.appendChild(button);
var button = document.createElement("button");
button.innerHTML = "HIDE ALL";
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){iframe.contentWindow.postMessage({"target":"*", "remove":true}, '*');};
iframeContainer.appendChild(button);
for (var i=0;i<listOfStreamIDs.length;i++){
if (i!==0){
iframesrc+=",";
}
iframesrc+=listOfStreamIDs[i];
var button = document.createElement("button");
button.innerHTML = "SHOW "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.title = "Publish using: https://vdo.ninja/?push="+listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({"target":"*", "remove":true}, '*');
iframe.contentWindow.postMessage({"target":this.dataset.sid, "add":true, "settings":{"style":{"width":"100%", "height":"100%", "display":"block"}}}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
}
iframe.src = iframesrc;
iframeContainer.appendChild(iframe);
document.getElementById("container").appendChild(iframeContainer);
//////////// LISTEN FOR EVENTS
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
/// If you have a routing system setup, you could have just one global listener for all iframes instead.
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
if ("action" in e.data){
var outputWindow = document.createElement("div");
outputWindow.innerHTML = "event: "+e.data.action+"<br />";
outputWindow.style.border="1px dotted black";
iframeContainer.appendChild(outputWindow);
}
if ("streamIDs" in e.data){
var outputWindow = document.createElement("div");
outputWindow.innerHTML = "streamID list:<br />";
for (var key in e.data.streamIDs) {
outputWindow.innerHTML += "streamID: " + key + ", label:"+e.data.streamIDs[key] + "\n";
}
outputWindow.style.border="1px dotted black";
iframeContainer.appendChild(outputWindow);
}
});
}
</script>
</head>
<body>
<div id="container">
<button onclick="loadIframe();">CONNECT</button>
</div>
</body>
</html>

View File

@@ -1,6 +1,6 @@
<html>
<head>
<title>IFRAME Example</title>
<head>
<title>Add to Scene Controller - VDO.Ninja</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
@@ -64,7 +64,7 @@
var button = document.createElement("a");
button.innerHTML = "Invite "+listOfStreamIDs[i];
button.target = "_blank";
button.href = "../?room=teststeve123&password=1234&broadcast&transparent&autostart&nmb&nvb&gain=0&webcam&l=stevetest&push="+listOfStreamIDs[i]+"_pov";
button.href = "../?room=teststeve123&password=1234&broadcast&transparent&autostart&nmb&nvb&gain=0&webcam&l=stevetest&push="+listOfStreamIDs[i];
iframeContainer.appendChild(button);
var button = document.createElement("button");
@@ -74,12 +74,12 @@
iframe.contentWindow.postMessage({
action: "addScene",
value: "1",
target: listOfStreamIDs[i],
target: this.dataset.sid
}, '*');
iframe.contentWindow.postMessage({
action: "mic",
value: true,
target: listOfStreamIDs[i],
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
@@ -126,8 +126,20 @@
<div id="container">
<button onclick="loadIframe();">Go to Directors Room</button>
<br />
The password for guests is 1234<br />
<br />
<br />
Custom guest invites and toggles for add/removing from scene=1 are on the bottom.
<br />
<br />
Scene=1 link: <a target="_blank" href="https://vdo.ninja/?scene=1&room=teststeve123&password=1234">https://vdo.ninja/?scene=1&room=teststeve123&password=1234</a>
</div>
</body>
</html>
</html>

View File

@@ -1,5 +1,5 @@
<html>
<head><title>Twitch + Video</title>
<head><title>Big Mute Button Remote - VDO.Ninja</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
@@ -118,4 +118,4 @@ function loadIframes(url=false){
</script>
</body>
</html>
</html>

View File

@@ -13,15 +13,25 @@ var generateHash = function (str, length=false){
);
};
function toHexString(byteArray){
return Array.prototype.map.call(byteArray, function(byte){
return ('0' + (byte & 0xFF).toString(16)).slice(-2);
}).join('');
}
return Array.prototype.map.call(byteArray, function(byte){
return ('0' + (byte & 0xFF).toString(16)).slice(-2);
}).join('');
}
var password = prompt("Please enter the password");
password = password.trim();
password = encodeURIComponent(password);
generateHash(password + location.hostname, 4).then(function(hash) { // million to one error.
var salt = location.hostname;
if (location.hostname == "steveseguin.github.io"){ // allows github to be a backup ; passwords will still work
salt = "vdo.ninja";
} else if (["vdo.ninja","rtc.ninja","versus.cam","socialstream.ninja"].includes(location.hostname.split(".").slice(-2).join("."))){
salt = location.hostname.split(".").slice(-2).join("."); // official sub-domains will retain their passwords
}
// Million to one error with a hash length of 4, and still decent security if using a long randomized password.
// For more security, you can use a hash length of just 2 rather than 4; VDO.Ninja v27.10 and newer supports 1,2,3,4,5, or 6 hash lengths.
generateHash(password + salt, 4).then(function(hash) {
alert("hash value: "+hash)
});
</script></body></html>

View File

@@ -142,16 +142,21 @@
return out;
}
function logData(type, data) {
var span = document.createElement('span');
var entry = document.createElement('div');
if (type){
type = "<i>"+type.replace(/_/g, ' ')+"</i>";
}
entry.innerHTML = type + data;
span.appendChild(entry);
document.body.prepend(span);
}
function logData(type, data) {
var span = document.createElement('span');
var entry = document.createElement('div');
if (type){
var typeElement = document.createElement('i');
typeElement.textContent = type.replace(/_/g, ' ');
entry.appendChild(typeElement);
entry.appendChild(document.createTextNode(" "));
}
var message = document.createElement('span');
message.textContent = data;
entry.appendChild(message);
span.appendChild(entry);
document.body.prepend(span);
}
</script>
</head>
<body onload="loadIframe();">

186
examples/chatoverlay.html Normal file
View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VDON Chat Overlay</title>
<style>
@font-face {
font-family: 'Cousine';
src: url('fonts/Cousine-Bold.ttf') format('truetype');
}
body {
margin: 0;
padding: 0 10px;
height: 100%;
border: 0;
display: flex;
flex-direction: column-reverse;
position: absolute;
bottom: 0;
overflow: hidden;
max-width: 100%;
font-family: Arial, sans-serif;
}
#chat-overlay {
margin: 0;
background-color: black;
padding: 8px 8px 0px 8px;
color: white;
font-family: Cousine, monospace;
font-size: 3.2em;
line-height: 1.1em;
letter-spacing: 0.0em;
text-transform: uppercase;
text-shadow: 0.05em 0.05em 0px rgba(0,0,0,1);
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-all;
hyphens: auto;
display: inline-block;
}
#chat-overlay:empty {
display:none!important;
}
a {
color: white;
font-size: 1.2em;
text-transform: none;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-all;
hyphens: auto;
}
#input-form {
background-color: #f0f0f0;
padding: 20px;
border-radius: 5px;
margin-top: 20px;
}
#input-form label {
display: block;
margin-bottom: 5px;
}
#input-form input {
width: 100%;
padding: 5px;
margin-bottom: 10px;
}
#input-form button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
}
#input-form button:hover {
background-color: #45a049;
}
</style>
</head>
<body>
<div id="chat-overlay"></div>
<div id="input-form" style="display: none;">
<h2>Chat Overlay Configuration</h2>
<label for="room">Room:</label>
<input type="text" id="room" name="room">
<label for="view">View:</label>
<input type="text" id="view" name="view">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
<button onclick="startOverlay()">Start Overlay</button>
</div>
<script>
(function (w) {
w.URLSearchParams = w.URLSearchParams || function (searchString) {
var self = this;
self.searchString = searchString;
self.get = function (name) {
var results = new RegExp("[\?&]" + name + "=([^&#]*)").exec(self.searchString);
return results == null ? null : decodeURI(results[1]) || 0;
};
};
})(window);
var urlParams = new URLSearchParams(window.location.search);
function loadIframe() {
var view = urlParams.get("view") || "";
var room = urlParams.get("room") || "";
var password = urlParams.get("password") || "";
if (!view && !room) {
document.getElementById('input-form').style.display = 'block';
return;
}
var iframe = document.createElement("iframe");
iframe.allow = "autoplay";
var srcString = "./?datamode&label=chatOverlay" +
(room ? "&scene&room=" + room : "") +
(view ? "&view=" + view : "") +
(password ? "&password=" + password : "");
iframe.src = srcString;
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.border = "0";
document.body.appendChild(iframe);
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow) return;
console.log(e);
if ("gotChat" in e.data) {
logData(e.data.gotChat.label, e.data.gotChat.msg);
}
});
}
function startOverlay() {
var room = document.getElementById('room').value;
var view = document.getElementById('view').value;
var password = document.getElementById('password').value;
var newUrl = window.location.pathname + '?';
if (room) newUrl += 'room=' + encodeURIComponent(room) + '&';
if (view) newUrl += 'view=' + encodeURIComponent(view) + '&';
if (password) newUrl += 'password=' + encodeURIComponent(password) + '&';
newUrl = newUrl.slice(0, -1); // Remove the trailing '&' or '?'
window.location.href = newUrl;
}
function logData(type, data) {
var entry = document.createElement('div');
if (type) {
var typeElement = document.createElement('i');
typeElement.textContent = type.replace(/_/g, ' ');
entry.appendChild(typeElement);
entry.appendChild(document.createTextNode(" "));
}
var message = document.createElement('span');
message.textContent = data;
entry.appendChild(message);
document.getElementById('chat-overlay').prepend(entry);
}
window.onload = loadIframe;
</script>
</body>
</html>

131
examples/control.html Normal file
View File

@@ -0,0 +1,131 @@
<html>
<head>
<title>Legacy Layout Console - VDO.Ninja</title>
<style>
body {
margin:0;
padding:0;
height:100%;
width:100%;
border:0;
overflow:hidden;
}
</style>
</head>
<body id="body">
<button onclick='send({"abc1231":[0,0,50,50],abc1232:[50,0,50,50],abc1233:[0,50,50,50],abc1234:[50,50,50,50]});'>2x2</button>
<button onclick='send({"abc1231":[0,0,100,100],abc1232:[0, 0, 0, 0 ],abc1233:[0,0,0,0],abc1234:[0,0,0,0]});'>1</button>
<button onclick='send({"abc1231":[0,0,50 ,100],abc1232:[50,0,100,100],abc1233:[0,0,0,0],abc1234:[0,0,0,0]});'>2x1</button>
<button onclick='send({"abc1231":[0,0,100,100],abc1232:[70,70,20,20],abc1233:[0,0,0,0],abc1234:[0,0,0,0]});'>Pip</button>
<script>
// this app is mainly for demo purposes at this time. It has been depreciated.
function updateURL(param, force=false) {
var para = param.split('=')[0];
if (!(urlParams.has(para)) || (force)){
if (history.pushState){
var arr = window.location.href.split('?');
var newurl;
if (arr.length > 1 && arr[1] !== '') {
newurl = window.location.href + '&' +param;
} else {
newurl = window.location.href + '?' +param;
}
window.history.pushState({path:newurl},'',newurl);
}
}
}
(function (w) {
w.URLSearchParams = w.URLSearchParams || function (searchString) {
var self = this;
self.searchString = searchString;
self.get = function (name) {
var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(self.searchString);
if (results == null) {
return null;
}
else {
return decodeURI(results[1]) || 0;
}
};
};
})(window);
var urlParams = new URLSearchParams(window.location.search);
function generateStreamID(){
var text = "";
var possible = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
for (var i = 0; i < 7; i++){
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
};
var roomID = "undefined";
if (urlParams.has("room")){
roomID = urlParams.get("room");
} else {
roomID = generateStreamID();
updateURL("room="+roomID);
}
var url = document.URL.substr(0,document.URL.lastIndexOf('/'));
var mixerURL = url + "/mixer?room=" + encodeURIComponent(roomID);
var bodyElement = document.getElementById("body");
var linkWrapper = document.createElement("div");
var mixerLink = document.createElement("a");
mixerLink.href = mixerURL;
mixerLink.textContent = mixerURL;
linkWrapper.appendChild(mixerLink);
navigator.clipboard.writeText(mixerURL).then(() => {
/* clipboard successfully set */
}, () => {
/* clipboard write failed */
});
bodyElement.appendChild(linkWrapper);
var socket = new WebSocket("wss://api.action.wtf:666"); // api.action.wtf has been deprecated.
socket.onclose = function (){
setTimeout(function(){window.location.reload(true);},100);
};
socket.onopen = function (){
socket.send(JSON.stringify({"join":roomID}));
}
socket.addEventListener('message', function (event) {
if (event.data){
var data = JSON.parse(event.data);
log(data);
}
});
socket.onclose = function (){
setTimeout(function(){window.location.reload(true);},100);
};
var counter=0;
function send(scene){
counter+=1;
socket.send(JSON.stringify({"msg":true, "scene":scene, "id":counter}));
}
</script>
</body>
</html>

112
examples/custom_labels.html Normal file
View File

@@ -0,0 +1,112 @@
<html>
<head>
<title>Custom Labels Overlay - VDO.Ninja</title>
<meta charset="UTF-8">
<style>
body{
padding:0;
margin:0;
background-color: #0000;
overflow: hidden;
}
#vdoninja {
border:0;
margin:0;
padding:0;
display:block;
opacity:0;
}
#start {
margin:auto auto;
padding:10%;
font-size:200%;
position:absolute;
top:0;
left:0;
width:100%;
height:100%;
cursor:pointer;
}
#label {
left: 0;
top: 0;
font-size: 4vw;
position: absolute;
font-family:Tahoma;
text-shadow: 0 0 1vw white;
}
</style>
</head>
<body id="main">
<div class="aspect-ratio-container">
<iframe id="vdoninja"></iframe>
<div id="overlay">
<div id="label"></div>
</div>
<div id="start" onclick="start();" >
Join with this sample guest link: <a id="invite" href='https://vdo.ninja/?push=testoverlay123&autostart&webcam&label' target="_blank">https://vdo.ninja/?push=testoverlay123&autostart&webcam&label</a><br /><br />
Click anywhere to start playback in this window.
</div>
</div>
<script>
var overlay = document.getElementById("overlay");
var iframe = document.getElementById("vdoninja");
var label = document.getElementById("label");
const urlParams = new URLSearchParams(window.location.search);
const streamID = urlParams.get('view') || "testoverlay123";
document.getElementById("invite").href = "https://vdo.ninja/?autostart&webcam&label&push="+streamID;
document.getElementById("invite").innerText = "https://vdo.ninja/?autostart&webcam&label&push="+streamID
function loadIframe(url){
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
if (url==""){
url="./";
}
iframe.src = url;
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
console.log(e.data);
if (!e.data.action || !("value" in e.data)){return;}
if (e.data.action=="view-connection-info"){
if (e.data.value.label){
label.innerText = e.data.value.label.replace("_"," ");
} else {
label.innerText = "";
}
} else if (e.data.action=="view-connection"){
if (e.data.value){
} else {
label.innerText = "";
}
}
});
}
function start(){
document.querySelector("#start").remove();
loadIframe("https://vdo.ninja/?transparent&cleanoutput&novideo&noaudio&novideo&dataonly&view="+streamID); // vdo.ninja/?push=testoverlay123&label=Test_Label&autostart&webcam
}
if (window.obsstudio){
start();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,149 @@
<html>
<head>
<title>Dynamic Overlay Frame - VDO.Ninja</title>
<meta charset="UTF-8">
<style>
body{
padding:0;
margin:0;
background-color: #0000;
overflow: hidden;
}
#vdoninja {
border:0;
margin:0;
padding:0;
display:block;
width:100%;
height:100%;
position:absolute;
top:0;
left:0;
animation: none;
}
#overlay{
border:0;
margin:0;
padding:0;
width:100%;
height:100%;
text-align:right;
position:absolute;
top:0;
left:0;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
display:none;
}
.fadein {
animation: fadeIn 200ms ease-in;
display: block!important;
}
@keyframes fadeIn {
0% { opacity: 0; }
20% { opacity: 0; }
100% { opacity: 1; }
}
#start {
margin:auto auto;
padding:10%;
font-size:200%;
position:absolute;
top:0;
left:0;
width:100%;
height:100%;
cursor:pointer;
}
#label {
left: 6vw;
bottom: 3.5vw;
font-size: 4vw;
position: absolute;
font-family:Tahoma;
text-shadow: 0 0 1vw white;
}
.aspect-ratio-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
height: 0;
overflow: hidden;
}
</style>
</head>
<body id="main">
<div class="aspect-ratio-container">
<iframe id="vdoninja"></iframe>
<div id="overlay">
<div id="label"></div>
</div>
<div id="start" onclick="start();" >
Join with this sample guest link: <a href='https://vdo.ninja/?push=testoverlay123&autostart&webcam&label' target="_blank">https://vdo.ninja/?push=testoverlay123&autostart&webcam&label</a><br /><br />
Click anywhere to start playback in this window.
</div>
</div>
<script>
var overlay = document.getElementById("overlay");
var iframe = document.getElementById("vdoninja");
var label = document.getElementById("label");
function loadIframe(url){
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
if (url==""){
url="./";
}
iframe.src = url;
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
console.log(e.data);
if (!e.data.action || !("value" in e.data)){return;}
if (e.data.action=="view-connection-info"){
if (e.data.value.label){
overlay.style.backgroundImage = "url(../media/overlay2.png)";
label.innerText = e.data.value.label.replace("_"," ");
} else {
overlay.style.backgroundImage = "url(../media/overlay1.png)";
label.innerText = "";
}
} else if (e.data.action=="view-connection"){
if (e.data.value){
overlay.classList.add("fadein");
} else {
overlay.classList.remove("fadein");
label.innerText = "";
}
}
});
}
function start(){
document.querySelector("#start").remove();
loadIframe("https://vdo.ninja/?view=testoverlay123&transparent&cleanoutput&animate=0"); // vdo.ninja/?push=testoverlay123&label=Test_Label&autostart&webcam
}
if (window.obsstudio){
start();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<html>
<head>
<meta charset="utf-8" />
<title>Custom Video Switcher - VDO.Ninja</title>
</head>
<body>
<button onclick="togglePlayer('STREAM123a')" >toggle video 1</button>
<button onclick="togglePlayer('STREAM123b')" >toggle video 2</button>
<iframe id="scene"
style="position: relative; display:block; width:100%; height: calc(100vh - 50px);"
allow="document-domain;encrypted-media;sync-xhr;cross-origin-isolated;accelerometer;midi;autoplay;fullscreen;picture-in-picture;display-capture;"
src="https://vdo.ninja/alpha/?room=ROOMHERE123&cleanoutput&transparent&noaudio&controls=0&noap&optimize=0&scale=100&scene&manual&b64css=dmlkZW97CiAgICBwb3NpdGlvbjogYWJzb2x1dGU7CiAgICBsZWZ0OiAwOwogICAgdG9wOiAwOwp9"
></iframe>
<script>
// you can remotely switch between video streams A and B, instead of using the toggle buttons. You'll need your own API service to switch, but that's up to you.
// https://vdo.ninja/alpha/?room=ROOMHERE123&push=STREAM123a&view <= invite guest-a
// https://vdo.ninja/alpha/?room=ROOMHERE123&push=STREAM123b&view <= invite guest-b
// &b64css=dmlkZW97CiAgICBwb3NpdGlvbjogYWJzb2x1dGU7CiAgICBsZWZ0OiAwOwogICAgdG9wOiAwOwp9" -- makes sure all videos added align to the top-left corner, overlapping other videos if needed
// we do not use &fadein=0, as that can cause flicker
// &manual disables the auto mixer for scene=0, so we can manually add/remove elements to the scene via the IFRAME API instead
var scene = document.getElementById("scene");
//////////// LISTEN FOR EVENTS
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
eventer(messageEvent, function (e) {
if (scene && e.source == scene.contentWindow){
if (e.data.action === 'view-connection') {
console.log(e.data);
} else if (e.data.action === 'end-view-connection') {
console.log(e.data);
}
}
})
var activeVideo = null;
async function togglePlayer(video){
activeVideo = video;
scene.contentWindow.postMessage({
add: true,
target: activeVideo
}, '*');
setTimeout(function(){
scene.contentWindow.postMessage({
replace: true, // this replaces all videos in the current scene with the target stream ID. We coudl use `remove` instead, but that requires specifying the streamID to remove
target: activeVideo
}, '*');
},500);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,489 @@
<!DOCTYPE html>
<html>
<head>
<title>VDO.Ninja DataChannel Pub/Sub Example</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.section {
border: 1px solid #ddd;
padding: 20px;
border-radius: 8px;
}
.messages {
height: 300px;
overflow-y: auto;
border: 1px solid #eee;
padding: 10px;
margin: 10px 0;
background: #f5f5f5;
}
.message {
margin: 5px 0;
padding: 5px;
background: white;
border-radius: 4px;
}
.controls {
display: flex;
gap: 10px;
margin: 10px 0;
}
input, button, select {
padding: 8px;
font-size: 14px;
}
input[type="text"] {
flex: 1;
}
.status {
padding: 10px;
background: #e0e0e0;
border-radius: 4px;
margin: 10px 0;
}
.status.connected {
background: #c8e6c9;
}
.status.error {
background: #ffcdd2;
}
.label-badge {
display: inline-block;
padding: 2px 8px;
background: #2196F3;
color: white;
border-radius: 12px;
font-size: 12px;
margin: 0 4px;
}
h3 {
margin-top: 0;
}
code {
background: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-size: 13px;
}
</style>
</head>
<body>
<h1>VDO.Ninja DataChannel Pub/Sub Example</h1>
<p>This example demonstrates replacing IFRAME-based messaging with the DataChannel SDK.</p>
<div class="container">
<div class="section">
<h3>Publisher Node</h3>
<div class="status" id="pubStatus">Disconnected</div>
<div class="controls">
<input type="text" id="pubRoom" placeholder="Room name" value="demo-room">
<input type="text" id="pubLabel" placeholder="Label" value="sensor-1">
<button onclick="connectPublisher()">Connect</button>
</div>
<h4>Publish Message</h4>
<div class="controls">
<input type="text" id="pubMessage" placeholder="Message to send">
<select id="pubTarget">
<option value="all">Broadcast to All</option>
<option value="label">To Label</option>
<option value="streamid">To Stream ID</option>
</select>
<input type="text" id="pubTargetValue" placeholder="Target label/ID" style="display:none;">
<button onclick="publishMessage()">Send</button>
</div>
<h4>Stream Data</h4>
<div class="controls">
<input type="file" id="fileInput">
<button onclick="streamFile()">Stream File</button>
</div>
<div class="messages" id="pubMessages"></div>
</div>
<div class="section">
<h3>Subscriber Node</h3>
<div class="status" id="subStatus">Disconnected</div>
<div class="controls">
<input type="text" id="subRoom" placeholder="Room name" value="demo-room">
<input type="text" id="subLabel" placeholder="Label" value="controller">
<button onclick="connectSubscriber()">Connect</button>
</div>
<h4>Subscribe to Labels</h4>
<div class="controls">
<input type="text" id="subToLabel" placeholder="Label to subscribe" value="sensor-1">
<button onclick="subscribeToLabel()">Subscribe</button>
</div>
<div id="subscriptions"></div>
<h4>Received Messages</h4>
<div class="messages" id="subMessages"></div>
<h4>Received Streams</h4>
<div id="streams"></div>
</div>
</div>
<div class="section" style="margin-top: 20px;">
<h3>Mesh Network Status</h3>
<div id="meshStatus"></div>
<canvas id="meshCanvas" width="800" height="400" style="border: 1px solid #ddd;"></canvas>
</div>
<div class="section" style="margin-top: 20px;">
<h3>Code Example (Replacing IFRAME)</h3>
<h4>Before (with IFRAME):</h4>
<pre><code>&lt;iframe src="https://vdo.ninja/?room=myroom&amp;push=cam1&amp;datachannel=true"
style="display:none"&gt;&lt;/iframe&gt;
// Communicate via postMessage
iframe.contentWindow.postMessage({data: 'hello'}, '*');</code></pre>
<h4>After (with SDK):</h4>
<pre><code>const node = new VDONinjaDataChannel();
await node.joinRoom({room: 'myroom', streamID: 'cam1'});
// Direct data channel communication
node.publish({data: 'hello'});
// Subscribe to specific labels
node.subscribe('sensors');
node.addEventListener('data', (e) => {
console.log('Received:', e.detail.data);
});</code></pre>
</div>
<script src="../vdoninja-datachannel-sdk.js"></script>
<script>
let publisher = null;
let subscriber = null;
const meshNodes = new Map();
// Target selection handler
document.getElementById('pubTarget').addEventListener('change', (e) => {
const targetInput = document.getElementById('pubTargetValue');
if (e.target.value === 'all') {
targetInput.style.display = 'none';
} else {
targetInput.style.display = 'block';
targetInput.placeholder = e.target.value === 'label' ? 'Target label' : 'Target stream ID';
}
});
async function connectPublisher() {
if (publisher) {
publisher.disconnect();
}
publisher = new VDONinjaDataChannel({
debug: true,
meshMode: 'partial'
});
// Setup event handlers
publisher.addEventListener('connected', () => {
updateStatus('pubStatus', 'Connected', true);
addMessage('pubMessages', 'Connected to server', 'system');
});
publisher.addEventListener('disconnected', () => {
updateStatus('pubStatus', 'Disconnected', false);
});
publisher.addEventListener('peer-connected', (e) => {
addMessage('pubMessages', `Peer connected: ${e.detail.uuid}`, 'system');
updateMeshVisualization();
});
publisher.addEventListener('data', (e) => {
addMessage('pubMessages',
`Received from ${e.detail.label || e.detail.streamID}: ${JSON.stringify(e.detail.data)}`,
'received'
);
});
try {
await publisher.connect();
await publisher.joinRoom({
room: document.getElementById('pubRoom').value,
streamID: `pub-${Date.now()}`,
label: document.getElementById('pubLabel').value
});
meshNodes.set(publisher.uuid, {
sdk: publisher,
type: 'publisher',
label: publisher.label
});
updateStatus('pubStatus', `Connected as ${publisher.label}`, true);
} catch (error) {
updateStatus('pubStatus', `Error: ${error.message}`, false);
}
}
async function connectSubscriber() {
if (subscriber) {
subscriber.disconnect();
}
subscriber = new VDONinjaDataChannel({
debug: true,
meshMode: 'partial'
});
// Setup event handlers
subscriber.addEventListener('connected', () => {
updateStatus('subStatus', 'Connected', true);
addMessage('subMessages', 'Connected to server', 'system');
});
subscriber.addEventListener('disconnected', () => {
updateStatus('subStatus', 'Disconnected', false);
});
subscriber.addEventListener('peer-connected', (e) => {
addMessage('subMessages', `Peer connected: ${e.detail.uuid}`, 'system');
updateMeshVisualization();
});
subscriber.addEventListener('data', (e) => {
addMessage('subMessages',
`From ${e.detail.label || e.detail.streamID}: ${JSON.stringify(e.detail.data)}`,
'received'
);
});
subscriber.addEventListener('stream', (e) => {
handleReceivedStream(e.detail);
});
try {
await subscriber.connect();
await subscriber.joinRoom({
room: document.getElementById('subRoom').value,
streamID: `sub-${Date.now()}`,
label: document.getElementById('subLabel').value
});
meshNodes.set(subscriber.uuid, {
sdk: subscriber,
type: 'subscriber',
label: subscriber.label
});
updateStatus('subStatus', `Connected as ${subscriber.label}`, true);
} catch (error) {
updateStatus('subStatus', `Error: ${error.message}`, false);
}
}
function publishMessage() {
if (!publisher || !publisher.connected) {
alert('Publisher not connected');
return;
}
const message = document.getElementById('pubMessage').value;
const target = document.getElementById('pubTarget').value;
const targetValue = document.getElementById('pubTargetValue').value;
if (!message) return;
const data = {
message,
timestamp: new Date().toISOString(),
from: publisher.label
};
let options = {};
if (target === 'label' && targetValue) {
options.toLabel = targetValue;
} else if (target === 'streamid' && targetValue) {
options.toStreamID = targetValue;
}
publisher.publish(data, options);
addMessage('pubMessages',
`Sent to ${target === 'all' ? 'all' : `${target}: ${targetValue}`}: ${message}`,
'sent'
);
document.getElementById('pubMessage').value = '';
}
async function subscribeToLabel() {
if (!subscriber || !subscriber.connected) {
alert('Subscriber not connected');
return;
}
const label = document.getElementById('subToLabel').value;
if (!label) return;
subscriber.subscribe(label);
// Update UI
const subDiv = document.getElementById('subscriptions');
const badge = document.createElement('span');
badge.className = 'label-badge';
badge.textContent = label;
subDiv.appendChild(badge);
addMessage('subMessages', `Subscribed to label: ${label}`, 'system');
// Auto-connect to peers with this label
subscriber.peerLabels.forEach((peerLabel, uuid) => {
if (peerLabel === label) {
const streamID = subscriber.peerStreamIDs.get(uuid);
if (streamID) {
subscriber.view(streamID);
}
}
});
}
async function streamFile() {
if (!publisher || !publisher.connected) {
alert('Publisher not connected');
return;
}
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const streamId = `file-${Date.now()}`;
publisher.streamData(streamId, e.target.result, {
fileName: file.name,
fileType: file.type,
fileSize: file.size
});
addMessage('pubMessages', `Streaming file: ${file.name} (${file.size} bytes)`, 'sent');
};
reader.readAsArrayBuffer(file);
}
function handleReceivedStream(stream) {
const streamsDiv = document.getElementById('streams');
const streamDiv = document.createElement('div');
streamDiv.className = 'message';
const blob = new Blob([stream.data], { type: stream.metadata.fileType || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
streamDiv.innerHTML = `
<strong>Received Stream:</strong> ${stream.metadata.fileName || 'Unknown'}
(${stream.metadata.fileSize || stream.data.byteLength} bytes)
<a href="${url}" download="${stream.metadata.fileName || 'download'}">Download</a>
`;
streamsDiv.appendChild(streamDiv);
}
function updateStatus(elementId, text, connected) {
const element = document.getElementById(elementId);
element.textContent = text;
element.className = 'status' + (connected ? ' connected' : '');
}
function addMessage(containerId, text, type = 'info') {
const container = document.getElementById(containerId);
const message = document.createElement('div');
message.className = 'message';
const time = new Date().toLocaleTimeString();
const typeEmoji = {
system: '🔧',
sent: '📤',
received: '📥',
info: ''
};
message.innerHTML = `${typeEmoji[type] || ''} [${time}] ${text}`;
container.appendChild(message);
container.scrollTop = container.scrollHeight;
}
function updateMeshVisualization() {
const canvas = document.getElementById('meshCanvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Simple mesh visualization
const nodes = Array.from(meshNodes.values());
const angleStep = (2 * Math.PI) / nodes.length;
const radius = 150;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// Draw nodes
nodes.forEach((node, index) => {
const x = centerX + radius * Math.cos(index * angleStep);
const y = centerY + radius * Math.sin(index * angleStep);
ctx.beginPath();
ctx.arc(x, y, 20, 0, 2 * Math.PI);
ctx.fillStyle = node.type === 'publisher' ? '#4CAF50' : '#2196F3';
ctx.fill();
ctx.fillStyle = 'white';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(node.label || 'Node', x, y + 4);
// Draw connections
if (node.sdk && node.sdk.peers) {
node.sdk.peers.forEach((pc, peerUuid) => {
const peerNode = meshNodes.get(peerUuid);
if (peerNode) {
const peerIndex = nodes.indexOf(peerNode);
const peerX = centerX + radius * Math.cos(peerIndex * angleStep);
const peerY = centerY + radius * Math.sin(peerIndex * angleStep);
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(peerX, peerY);
ctx.strokeStyle = '#666';
ctx.stroke();
}
});
}
});
// Update status text
const statusDiv = document.getElementById('meshStatus');
statusDiv.innerHTML = `<strong>Nodes:</strong> ${nodes.length} |
<strong>Publishers:</strong> ${nodes.filter(n => n.type === 'publisher').length} |
<strong>Subscribers:</strong> ${nodes.filter(n => n.type === 'subscriber').length}`;
}
// Auto-connect on load for demo
window.addEventListener('load', () => {
// Optional: auto-connect for easier testing
// connectPublisher();
// connectSubscriber();
});
</script>
</body>
</html>

531
examples/dataiframes.md Normal file
View File

@@ -0,0 +1,531 @@
# VDO.Ninja IFRAME API: Generic P2P Data Transmission Guide
This guide focuses specifically on how to send and receive generic data between clients using VDO.Ninja's peer-to-peer (P2P) data channels.
## Understanding the P2P Data Channels
VDO.Ninja provides a powerful API that allows websites to send arbitrary data between connected clients through its peer-to-peer infrastructure. This enables you to:
- Create custom communication channels between clients
- Implement application-specific data exchange
- Build interactive multi-user experiences
- Exchange any type of serializable data
## Why VDO.Ninja's P2P Data Channels Are Powerful
VDO.Ninja's data channels offer several compelling advantages that make them ideal for modern web applications:
- **Production-Proven Reliability**: Used in production applications like Social Stream Ninja, which processes hundreds of messages per minute per peer connection
- **Automatic LAN Optimization**: Detects when connections are on the same local network and routes data directly, reducing latency
- **Firewall Traversal**: Enables communication between devices behind different firewalls without port forwarding
- **Cost-Effective**: No server costs or bandwidth charges for data transmission, as everything happens peer-to-peer
- **Low Latency**: Direct connections between peers minimize delay, ideal for real-time applications
- **Scalability**: Each peer connects directly to others, distributing the load across the network
- **AI Integration Ready**: Perfect for distributing AI processing tasks or sharing AI-generated content between users
- **Remote Control Applications**: Enables secure remote control of devices through firewalls without complex networking setups
- **Works Across Platforms**: Functions on mobile, desktop, and various browsers without additional plugins
The creators of VDO.Ninja use these data channels in numerous applications beyond video, demonstrating their versatility and reliability in real-world scenarios.
## Basic Setup
First, set up your VDO.Ninja iframe:
```javascript
// Create the iframe element
var iframe = document.createElement("iframe");
// Set necessary permissions
iframe.allow = "camera;microphone;fullscreen;display-capture;autoplay;";
// Set the source URL (your VDO.Ninja room)
iframe.src = "https://vdo.ninja/?room=your-room-name&cleanoutput";
// Add the iframe to your page
document.getElementById("container").appendChild(iframe);
```
## Setting Up Event Listeners
To receive data from other clients, set up an event listener:
```javascript
// Set up event listener (cross-browser compatible)
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
// Connected peers storage
var connectedPeers = {};
// Add the event listener
eventer(messageEvent, function(e) {
// Make sure the message is from our VDO.Ninja iframe
if (e.source != iframe.contentWindow) return;
// Process connection events to track connected peers
if ("action" in e.data) {
handleConnectionEvents(e.data);
}
// Handle received data
if ("dataReceived" in e.data) {
handleDataReceived(e.data.dataReceived, e.data.UUID);
}
}, false);
function handleConnectionEvents(data) {
if (data.action === "guest-connected" && data.streamID) {
// Store connected peer information
connectedPeers[data.streamID] = data.value?.label || "Guest";
console.log("Guest connected:", data.streamID, "Label:", connectedPeers[data.streamID]);
}
else if (data.action === "push-connection" && data.value === false && data.streamID) {
// Remove disconnected peers
console.log("Guest disconnected:", data.streamID);
delete connectedPeers[data.streamID];
}
}
function handleDataReceived(data, senderUUID) {
console.log("Data received from:", senderUUID, "Data:", data);
// Example: Check for your custom data namespace
if (data.overlayNinja) {
processCustomData(data.overlayNinja, senderUUID);
}
}
function processCustomData(data, senderUUID) {
// Process based on your application's needs
console.log("Processing custom data:", data);
// Example: Handle different data types
if (data.message) {
displayMessage(data.message);
} else if (data.command) {
executeCommand(data.command);
}
}
```
## Sending Data
### Send Data Structure
When sending data via the VDO.Ninja IFRAME API, you use this general format:
```javascript
iframe.contentWindow.postMessage({
sendData: yourDataPayload,
type: "pcs", // Connection type (see below)
UUID: targetUUID // Optional: specific target
}, "*");
```
The components are:
- `sendData`: Your data payload (object)
- `type`: Connection type (string)
- `"pcs"`: Use peer connections (most reliable)
- `"rpcs"`: Use request-based connections
- `UUID` or `streamID`: Optional target identifier
### Sending to All Connected Peers
```javascript
function sendDataToAllPeers(data) {
// Create the data structure with your custom namespace
var payload = {
overlayNinja: data // Your custom data under a namespace
};
// Send to all peers
iframe.contentWindow.postMessage({
sendData: payload,
type: "pcs" // Use peer connection for reliability
}, "*");
}
// Example usage
sendDataToAllPeers({
message: "Hello everyone!",
timestamp: Date.now()
});
```
### Sending to a Specific Peer by UUID
```javascript
function sendDataToPeer(data, targetUUID) {
// Create the data structure
var payload = {
overlayNinja: data // Your custom data
};
// Send to specific UUID
iframe.contentWindow.postMessage({
sendData: payload,
type: "pcs",
UUID: targetUUID
}, "*");
}
// Example usage
sendDataToPeer({
message: "Hello specific peer!",
timestamp: Date.now()
}, "peer-uuid-123");
```
### Sending to Peers with Specific Labels
```javascript
function sendDataByLabel(data, targetLabel) {
// Create the data structure
var payload = {
overlayNinja: data // Your custom data
};
// Iterate through connected peers to find those with matching label
var keys = Object.keys(connectedPeers);
for (var i = 0; i < keys.length; i++) {
try {
var UUID = keys[i];
var label = connectedPeers[UUID];
if (label === targetLabel) {
// Send to this specific peer
iframe.contentWindow.postMessage({
sendData: payload,
type: "pcs",
UUID: UUID
}, "*");
}
} catch (e) {
console.error("Error sending to peer:", e);
}
}
}
// Example usage
sendDataByLabel({
message: "Hello all viewers!",
timestamp: Date.now()
}, "viewer");
```
### Sending to a Peer by StreamID
```javascript
function sendDataByStreamID(data, streamID) {
// Create the data structure
var payload = {
overlayNinja: data // Your custom data
};
// Send to specific streamID
iframe.contentWindow.postMessage({
sendData: payload,
type: "pcs",
streamID: streamID
}, "*");
}
// Example usage
sendDataByStreamID({
message: "Hello by stream ID!",
timestamp: Date.now()
}, "stream-123");
```
## Tracking Connected Peers
To reliably communicate with peers, keep track of connections and disconnections:
```javascript
// Store connected peers
var connectedPeers = {};
function handleConnectionEvents(data) {
// Guest connections
if (data.action === "guest-connected" && data.streamID) {
connectedPeers[data.streamID] = data.value?.label || "Guest";
console.log("Guest connected:", data.streamID, "Label:", connectedPeers[data.streamID]);
}
// View connections
else if (data.action === "view-connection") {
if (data.value && data.streamID) {
connectedPeers[data.streamID] = "Viewer";
console.log("Viewer connected:", data.streamID);
} else if (data.streamID) {
console.log("Viewer disconnected:", data.streamID);
delete connectedPeers[data.streamID];
}
}
// Director connections
else if (data.action === "director-connected") {
console.log("Director connected");
}
// Handle disconnections
else if (data.action === "push-connection" && data.value === false && data.streamID) {
console.log("User disconnected:", data.streamID);
delete connectedPeers[data.streamID];
}
}
```
## Getting All Connected StreamIDs
You can request a list of all connected streams:
```javascript
function getConnectedPeers() {
iframe.contentWindow.postMessage({ getStreamIDs: true }, "*");
}
// In your event listener, handle the response:
if ("streamIDs" in e.data) {
console.log("Connected streams:");
for (var key in e.data.streamIDs) {
console.log("StreamID:", key, "Label:", e.data.streamIDs[key]);
}
}
```
## Detailed State Information
For more comprehensive information about the current state:
```javascript
function getDetailedState() {
iframe.contentWindow.postMessage({ getDetailedState: true }, "*");
}
// Handle the response in your event listener
```
## Data Structure Best Practices
1. **Use a Namespace**: Put your data under a custom namespace to avoid conflicts
```javascript
{
sendData: {
yourAppName: {
// Your data here
}
}
}
```
2. **Include Type Information**: Include type identifiers to differentiate messages
```javascript
{
sendData: {
yourAppName: {
type: "command",
data: { /* command data */ }
}
}
}
```
3. **Include Timestamp**: Add timestamps to help with ordering
```javascript
{
sendData: {
yourAppName: {
type: "update",
data: { /* update data */ },
timestamp: Date.now()
}
}
}
```
## Complete Example: Simple Chat System
Here's a complete example implementing a simple chat system using the P2P data channels:
```javascript
// Create the interface
const container = document.createElement('div');
container.style.width = '100%';
container.style.maxWidth = '800px';
container.style.margin = '0 auto';
document.body.appendChild(container);
// Create VDO.Ninja iframe
const iframe = document.createElement('iframe');
iframe.allow = "camera;microphone;fullscreen;display-capture;autoplay;";
iframe.src = "https://vdo.ninja/?room=chat-demo&cleanoutput";
iframe.style.width = "100%";
iframe.style.height = "360px";
container.appendChild(iframe);
// Create chat interface
const chatContainer = document.createElement('div');
chatContainer.style.marginTop = '20px';
container.appendChild(chatContainer);
const chatMessages = document.createElement('div');
chatMessages.style.height = '300px';
chatMessages.style.border = '1px solid #ccc';
chatMessages.style.padding = '10px';
chatMessages.style.overflowY = 'scroll';
chatContainer.appendChild(chatMessages);
const inputContainer = document.createElement('div');
inputContainer.style.marginTop = '10px';
inputContainer.style.display = 'flex';
chatContainer.appendChild(inputContainer);
const messageInput = document.createElement('input');
messageInput.type = 'text';
messageInput.placeholder = 'Type your message...';
messageInput.style.flexGrow = '1';
messageInput.style.padding = '8px';
inputContainer.appendChild(messageInput);
const sendButton = document.createElement('button');
sendButton.textContent = 'Send';
sendButton.style.marginLeft = '10px';
sendButton.style.padding = '8px 16px';
inputContainer.appendChild(sendButton);
// Store connected peers
const connectedPeers = {};
// Add event listeners
sendButton.addEventListener('click', sendChatMessage);
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendChatMessage();
}
});
function sendChatMessage() {
const message = messageInput.value.trim();
if (message) {
// Create message object
const chatData = {
type: 'chat',
text: message,
sender: 'Me',
timestamp: Date.now()
};
// Add to local chat
addMessageToChat(chatData.sender, chatData.text);
// Send to all peers
sendDataToAllPeers(chatData);
// Clear input
messageInput.value = '';
}
}
function addMessageToChat(sender, text) {
const messageElement = document.createElement('div');
messageElement.style.marginBottom = '8px';
const senderSpan = document.createElement('strong');
senderSpan.textContent = sender + ': ';
messageElement.appendChild(senderSpan);
const textNode = document.createTextNode(text);
messageElement.appendChild(textNode);
chatMessages.appendChild(messageElement);
// Scroll to bottom
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function sendDataToAllPeers(data) {
// Create the data structure
const payload = {
chatApp: data // Using a custom namespace
};
// Send to all peers
iframe.contentWindow.postMessage({
sendData: payload,
type: "pcs"
}, "*");
}
// Set up event listener for messages from iframe
const eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
const eventer = window[eventMethod];
const messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
eventer(messageEvent, function(e) {
// Make sure the message is from our VDO.Ninja iframe
if (e.source != iframe.contentWindow) return;
// Process connection events
if ("action" in e.data) {
handleConnectionEvents(e.data);
}
// Handle received data
if ("dataReceived" in e.data) {
handleDataReceived(e.data.dataReceived, e.data.UUID);
}
}, false);
function handleConnectionEvents(data) {
if (data.action === "guest-connected" && data.streamID) {
// Store connected peer information
connectedPeers[data.streamID] = data.value?.label || "Guest";
console.log("Guest connected:", data.streamID, "Label:", connectedPeers[data.streamID]);
// Announce new connection in chat
addMessageToChat("System", `${connectedPeers[data.streamID]} joined the chat`);
}
else if (data.action === "push-connection" && data.value === false && data.streamID) {
// Announce disconnection
if (connectedPeers[data.streamID]) {
addMessageToChat("System", `${connectedPeers[data.streamID]} left the chat`);
}
// Remove from tracking
console.log("Guest disconnected:", data.streamID);
delete connectedPeers[data.streamID];
}
}
function handleDataReceived(data, senderUUID) {
// Check for chat messages
if (data.chatApp && data.chatApp.type === 'chat') {
const chatData = data.chatApp;
// Get sender name from our peer tracking if available
const senderName = connectedPeers[senderUUID] || chatData.sender || "Unknown";
// Add to chat
addMessageToChat(senderName, chatData.text);
}
}
```
## Best Practices
1. **Track Connections**: Always maintain a list of connected peers
2. **Use Namespaces**: Organize your data under custom namespaces
3. **Add Type Information**: Include message types for easier processing
4. **Include Timestamps**: Help with ordering and synchronization
5. **Error Handling**: Use try/catch blocks when sending messages
6. **Data Size**: Keep payloads reasonably small to avoid performance issues
7. **UUID vs StreamID**: Prefer UUID for targeting as it's more stable
## Troubleshooting
- **No Data Received**: Verify the UUID or streamID is correct
- **Connection Issues**: Check if peers are properly connected before sending
- **Timing Problems**: Ensure the iframe is fully loaded before sending messages
- **Data Format**: Make sure your data is properly serializable
- **Security Settings**: Check that your iframe permissions are set correctly
By following this guide, you can implement robust P2P data exchange between VDO.Ninja clients for any custom application.

View File

@@ -1,5 +1,5 @@
<html>
<head><title>Dual Input</title>
<head><title>Draggable Multi-View - VDO.Ninja</title>
<style>
body{
padding:0;
@@ -125,7 +125,7 @@ button{
</head>
<body>
<input placeholder="Enter an OBS.Ninja Room Link" id="viewlink" />
<input placeholder="Enter an VDO.Ninja Room Link" id="viewlink" />
<button onclick="loadIframe();">Load URL</button>You can drag and resize the generated windows; multiple can be created.
@@ -313,7 +313,7 @@ function loadIframe(){
var iframe = document.createElement("iframe");
iframe.allow="autoplay";
iframe.src = document.getElementById("viewlink").value || "https://obs.ninja";
iframe.src = document.getElementById("viewlink").value || "https://vdo.ninja";
iframe.style.width="325px";
iframe.style.height="420px";
@@ -328,4 +328,4 @@ function loadIframe(){
</script>
</body>
</html>
</html>

View File

@@ -50,11 +50,11 @@ function loadIframes(url=false){
var path = window.location.host+window.location.pathname.split("/").slice(0,-1).join("/");
var room1 = "https://"+path+"/?room="+roomname+"&push="+roomname+"_front&webcam&autostart&vd=front&ad=1&exclude="+roomname+"_rear";
var room2 = "https://"+path+"/?room="+roomname+"&push="+roomname+"_rear&webcam&autostart&vd=back&ad=0&view&cleanoutput&nosettings&transparent";
var room1 = "https://"+path+"/../?room="+roomname+"&push="+roomname+"_front&webcam&autostart&vd=front&ad=1&exclude="+roomname+"_rear";
var room2 = "https://"+path+"/../?room="+roomname+"&push="+roomname+"_rear&webcam&autostart&vd=back&ad=0&view&cleanoutput&nosettings&transparent";
var iframe = document.createElement("iframe");
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
iframe.allow = "document-domain;encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
iframe.src = room1;
var iframeContainer = document.createElement("div");
iframeContainer.appendChild(iframe);

View File

@@ -0,0 +1,634 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VDO.Ninja SDK - Dynamic Viewer</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.container {
display: grid;
grid-template-columns: 350px 1fr;
gap: 20px;
}
.control-panel {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
height: fit-content;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 10px;
}
.video-container {
background: #000;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.video-container video {
width: 100%;
height: 240px;
object-fit: cover;
}
.video-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.7);
color: white;
padding: 5px 10px;
font-size: 14px;
}
.section {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.section:last-child {
border-bottom: none;
}
h2 {
margin-top: 0;
font-size: 20px;
color: #333;
}
h3 {
font-size: 16px;
margin: 10px 0 5px 0;
color: #666;
}
input, select {
width: 100%;
padding: 8px;
margin: 5px 0;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
button {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
margin: 5px 5px 5px 0;
font-size: 14px;
}
button:hover {
background: #0056b3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
button.danger {
background: #dc3545;
}
button.danger:hover {
background: #c82333;
}
button.success {
background: #28a745;
}
button.success:hover {
background: #218838;
}
.status {
padding: 10px;
margin: 10px 0;
background: #f8f9fa;
border-radius: 4px;
font-size: 14px;
border-left: 4px solid #007bff;
}
.status.error {
border-left-color: #dc3545;
background: #f8d7da;
color: #721c24;
}
.status.success {
border-left-color: #28a745;
background: #d4edda;
color: #155724;
}
.participant-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
padding: 5px;
font-size: 14px;
}
.participant {
padding: 5px;
margin: 2px 0;
background: #f8f9fa;
border-radius: 3px;
display: flex;
justify-content: space-between;
align-items: center;
}
.participant.connected {
background: #d4edda;
}
.tab-buttons {
display: flex;
gap: 5px;
margin-bottom: 15px;
}
.tab-button {
background: #f8f9fa;
color: #333;
flex: 1;
}
.tab-button.active {
background: #007bff;
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.stats {
font-family: monospace;
font-size: 12px;
background: #f8f9fa;
padding: 5px;
border-radius: 3px;
margin-top: 5px;
}
</style>
</head>
<body>
<h1>VDO.Ninja SDK - Dynamic Viewer</h1>
<div class="container">
<!-- Control Panel -->
<div class="control-panel">
<div class="section">
<h2>Connection</h2>
<input type="text" id="wss" placeholder="WebSocket Server" value="wss://apibackup.vdo.ninja/">
<button onclick="connect()" id="connectBtn">Connect</button>
<button onclick="disconnect()" id="disconnectBtn" disabled>Disconnect</button>
<div id="connectionStatus" class="status">Not connected</div>
</div>
<!-- Tabs -->
<div class="tab-buttons">
<button class="tab-button active" onclick="showTab('room')">Room View</button>
<button class="tab-button" onclick="showTab('direct')">Direct View</button>
</div>
<!-- Room View Tab -->
<div id="roomTab" class="tab-content active">
<div class="section">
<h2>Join Room</h2>
<input type="text" id="roomName" placeholder="Room Name">
<input type="password" id="roomPassword" placeholder="Password (optional)">
<button onclick="joinRoom()" id="joinRoomBtn" disabled>Join Room</button>
<button onclick="leaveRoom()" id="leaveRoomBtn" disabled>Leave Room</button>
<div id="roomStatus" class="status">Not in a room</div>
</div>
<div class="section">
<h2>Room Participants</h2>
<button onclick="refreshParticipants()" id="refreshBtn" disabled>Refresh</button>
<button onclick="viewAllInRoom()" id="viewAllBtn" disabled class="success">View All</button>
<div id="participantList" class="participant-list">
<em>Join a room to see participants</em>
</div>
</div>
</div>
<!-- Direct View Tab -->
<div id="directTab" class="tab-content">
<div class="section">
<h2>View Stream Directly</h2>
<input type="text" id="directStreamID" placeholder="Stream ID">
<input type="password" id="directPassword" placeholder="Password (optional)">
<button onclick="viewDirect()" id="viewDirectBtn" disabled>View Stream</button>
</div>
<div class="section">
<h2>Active Viewers</h2>
<button onclick="refreshActiveViewers()">Refresh</button>
<div id="activeViewersList" class="participant-list">
<em>No active viewers</em>
</div>
</div>
</div>
<div class="section">
<h2>Quick Actions</h2>
<button onclick="stopAllViewers()" class="danger">Stop All Viewers</button>
<button onclick="showStats()">Show Stats</button>
</div>
</div>
<!-- Video Grid -->
<div class="video-grid" id="videoGrid">
<div style="grid-column: 1/-1; text-align: center; color: #666; padding: 40px;">
<h3>No streams being viewed</h3>
<p>Connect and join a room or view streams directly</p>
</div>
</div>
</div>
<script src="../vdoninja-sdk.js"></script>
<script>
let sdk = null;
const viewers = new Map(); // streamID -> {pc, video, container}
// Initialize
function init() {
updateUI();
}
// Show tab
function showTab(tab) {
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
if (tab === 'room') {
document.querySelector('.tab-button:nth-child(1)').classList.add('active');
document.getElementById('roomTab').classList.add('active');
} else {
document.querySelector('.tab-button:nth-child(2)').classList.add('active');
document.getElementById('directTab').classList.add('active');
}
}
// Connect to signaling server
async function connect() {
const wss = document.getElementById('wss').value;
const status = document.getElementById('connectionStatus');
try {
status.textContent = 'Connecting...';
sdk = new VDONinjaSDK({
wss,
debug: true
});
// Setup event listeners
sdk.addEventListener('connected', () => {
status.className = 'status success';
status.textContent = 'Connected to signaling server';
updateUI();
});
sdk.addEventListener('disconnected', () => {
status.className = 'status error';
status.textContent = 'Disconnected from server';
updateUI();
});
sdk.addEventListener('someoneJoined', (e) => {
console.log('Someone joined:', e.detail);
setTimeout(refreshParticipants, 500);
});
sdk.addEventListener('peerListing', (e) => {
console.log('Peer listing:', e.detail);
refreshParticipants();
});
await sdk.connect();
} catch (error) {
status.className = 'status error';
status.textContent = `Connection failed: ${error.message}`;
sdk = null;
updateUI();
}
}
// Disconnect
async function disconnect() {
if (sdk) {
await stopAllViewers();
await sdk.disconnect();
sdk = null;
updateUI();
}
}
// Join room
async function joinRoom() {
const room = document.getElementById('roomName').value;
const password = document.getElementById('roomPassword').value;
const status = document.getElementById('roomStatus');
if (!room) {
status.className = 'status error';
status.textContent = 'Please enter a room name';
return;
}
try {
status.textContent = 'Joining room...';
await sdk.joinRoom({ room, password });
status.className = 'status success';
status.textContent = `Joined room: ${room}`;
updateUI();
// Refresh participants after a short delay
setTimeout(refreshParticipants, 1000);
} catch (error) {
status.className = 'status error';
status.textContent = `Failed to join room: ${error.message}`;
}
}
// Leave room
async function leaveRoom() {
const status = document.getElementById('roomStatus');
try {
await stopAllViewers();
await sdk.leaveRoom();
status.className = 'status';
status.textContent = 'Left room';
document.getElementById('participantList').innerHTML = '<em>Join a room to see participants</em>';
updateUI();
} catch (error) {
status.className = 'status error';
status.textContent = `Error leaving room: ${error.message}`;
}
}
// Refresh participants
function refreshParticipants() {
if (!sdk || !sdk.state.room) return;
const participants = sdk.getRoomParticipants();
const list = document.getElementById('participantList');
if (participants.length === 0) {
list.innerHTML = '<em>No other participants in room</em>';
return;
}
list.innerHTML = participants.map(p => {
const isViewing = viewers.has(p.streamID);
const label = p.label || p.streamID;
return `
<div class="participant ${p.connected ? 'connected' : ''}">
<span>${label}</span>
${isViewing ?
`<button onclick="stopViewing('${p.streamID}')">Stop</button>` :
`<button onclick="viewStream('${p.streamID}')">View</button>`
}
</div>
`;
}).join('');
}
// View all in room
async function viewAllInRoom() {
try {
const viewers = await sdk.viewAllInRoom();
for (const viewer of viewers) {
handleNewViewer(viewer.streamID, viewer.pc, viewer.label);
}
if (viewers.length === 0) {
alert('No streams to view in this room');
}
} catch (error) {
console.error('Error viewing all:', error);
alert(`Error: ${error.message}`);
}
}
// View specific stream
async function viewStream(streamID) {
try {
const pc = await sdk.view(streamID);
handleNewViewer(streamID, pc);
refreshParticipants();
refreshActiveViewers();
} catch (error) {
console.error(`Error viewing ${streamID}:`, error);
alert(`Failed to view stream: ${error.message}`);
}
}
// View direct (without room)
async function viewDirect() {
const streamID = document.getElementById('directStreamID').value;
const password = document.getElementById('directPassword').value;
if (!streamID) {
alert('Please enter a stream ID');
return;
}
try {
// If password provided, we need to set it temporarily
if (password) {
sdk.state.password = password;
await sdk._updateStreamIDHash();
}
const pc = await sdk.view(streamID);
handleNewViewer(streamID, pc);
refreshActiveViewers();
} catch (error) {
console.error('Error viewing directly:', error);
alert(`Failed to view stream: ${error.message}`);
}
}
// Handle new viewer connection
function handleNewViewer(streamID, pc, label) {
if (viewers.has(streamID)) {
console.log('Already viewing:', streamID);
return;
}
// Create video container
const container = document.createElement('div');
container.className = 'video-container';
container.id = `video-${streamID}`;
const video = document.createElement('video');
video.autoplay = true;
video.playsinline = true;
const labelDiv = document.createElement('div');
labelDiv.className = 'video-label';
labelDiv.innerHTML = `
${label || streamID}
<button style="float: right; background: #dc3545; border: none; color: white; padding: 2px 8px; border-radius: 3px; cursor: pointer;"
onclick="stopViewing('${streamID}')">×</button>
`;
container.appendChild(video);
container.appendChild(labelDiv);
// Handle tracks
pc.ontrack = (event) => {
console.log(`Track received from ${streamID}:`, event.track.kind);
video.srcObject = event.streams[0];
};
// Handle connection state
pc.onconnectionstatechange = () => {
console.log(`Connection state for ${streamID}:`, pc.connectionState);
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
stopViewing(streamID);
}
};
// Store viewer info
viewers.set(streamID, { pc, video, container });
// Update grid
updateVideoGrid();
}
// Stop viewing specific stream
async function stopViewing(streamID) {
const viewer = viewers.get(streamID);
if (!viewer) return;
// Stop video
if (viewer.video.srcObject) {
viewer.video.srcObject.getTracks().forEach(track => track.stop());
viewer.video.srcObject = null;
}
// Remove from DOM
viewer.container.remove();
// Close connection
await sdk.stopViewing(streamID);
// Remove from map
viewers.delete(streamID);
// Update UI
updateVideoGrid();
refreshParticipants();
refreshActiveViewers();
}
// Stop all viewers
async function stopAllViewers() {
const streamIDs = Array.from(viewers.keys());
for (const streamID of streamIDs) {
await stopViewing(streamID);
}
}
// Refresh active viewers list
function refreshActiveViewers() {
if (!sdk) return;
const active = sdk.getActiveViewers();
const list = document.getElementById('activeViewersList');
if (active.length === 0) {
list.innerHTML = '<em>No active viewers</em>';
return;
}
list.innerHTML = active.map(v => `
<div class="participant ${v.connectionState === 'connected' ? 'connected' : ''}">
<span>${v.streamID} (${v.connectionState})</span>
<button onclick="stopViewing('${v.streamID}')">Stop</button>
</div>
`).join('');
}
// Update video grid
function updateVideoGrid() {
const grid = document.getElementById('videoGrid');
if (viewers.size === 0) {
grid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; color: #666; padding: 40px;">
<h3>No streams being viewed</h3>
<p>Connect and join a room or view streams directly</p>
</div>
`;
} else {
grid.innerHTML = '';
for (const viewer of viewers.values()) {
grid.appendChild(viewer.container);
}
}
}
// Show stats
async function showStats() {
if (!sdk) {
alert('Not connected');
return;
}
try {
const stats = await sdk.getStats();
console.log('Connection stats:', stats);
let message = 'Connection Statistics:\n\n';
for (const [uuid, stat] of Object.entries(stats)) {
message += `Connection ${uuid}:\n`;
stat.forEach(report => {
if (report.type === 'inbound-rtp' || report.type === 'outbound-rtp') {
message += ` ${report.type}: ${report.bytesReceived || report.bytesSent || 0} bytes\n`;
}
});
}
alert(message);
} catch (error) {
alert(`Error getting stats: ${error.message}`);
}
}
// Update UI state
function updateUI() {
const connected = sdk && sdk.state.connected;
const inRoom = connected && sdk.state.room;
document.getElementById('connectBtn').disabled = connected;
document.getElementById('disconnectBtn').disabled = !connected;
document.getElementById('joinRoomBtn').disabled = !connected || inRoom;
document.getElementById('leaveRoomBtn').disabled = !inRoom;
document.getElementById('refreshBtn').disabled = !inRoom;
document.getElementById('viewAllBtn').disabled = !inRoom;
document.getElementById('viewDirectBtn').disabled = !connected;
}
// Initialize on load
init();
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
<html>
<head>
<title>IFRAME Example</title>
<title>Esports POV Toggler - VDO.Ninja</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
@@ -128,4 +128,4 @@
<button onclick="loadIframe();">CONNECT</button>
</div>
</body>
</html>
</html>

View File

@@ -0,0 +1,696 @@
<!DOCTYPE html>
<html>
<head>
<title>Controller Visualizer</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
font-family: Arial, sans-serif;
background: #1a1a1a;
color: #fff;
}
#controls {
margin: 20px;
padding: 10px;
background: #333;
border-radius: 5px;
display: flex;
gap: 10px;
align-items: center;
}
select, button {
padding: 5px;
background: #444;
color: #fff;
border: 1px solid #555;
border-radius: 3px;
}
button {
padding: 5px 10px;
background: #4CAF50;
border: none;
cursor: pointer;
}
.tooltip {
position: relative;
display: inline-block;
}
.tooltip:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 5px 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
}
button:hover { background: #45a049; }
#gamepad { width: 600px; height: 400px; }
.button { transition: fill 0.1s ease; }
.button.pressed { fill: #4CAF50; }
.stick { transition: transform 0.1s ease; }
#deviceLog {
width: 80%;
max-height: 200px;
overflow-y: auto;
background: #333;
padding: 10px;
margin: 10px;
border-radius: 5px;
font-family: monospace;
}
.controller-xbox, .controller-ps { display: none; }
.controller-xbox.active, .controller-ps.active { display: block; }
#mappingSelect { margin-left: 10px; }
.device-section {
background: #444;
padding: 10px;
margin: 5px 0;
border-radius: 5px;
}
.device-section h3 {
margin: 0 0 5px 0;
font-size: 14px;
color: #aaa;
}
.device-section.empty {
color: #888;
font-style: italic;
}
.device-option {
padding: 5px;
margin: 2px 0;
background: #555;
border-radius: 3px;
cursor: pointer;
}
.device-option:hover {
background: #666;
}
.device-option.active {
background: #4CAF50;
}
.device-option {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
margin-left: 8px;
background: #666;
transition: background-color 0.3s ease;
}
.status-indicator.connected {
background: #4CAF50;
}
.status-indicator.disconnected {
background: #f44336;
}
.device-info {
font-size: 12px;
color: #888;
margin-left: 8px;
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); }
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
}
.empty small {
display: block;
margin-top: 4px;
color: #666;
}
.controller-xbox, .controller-playstation {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
position: absolute;
}
.controller-xbox.active, .controller-playstation.active {
opacity: 1;
pointer-events: auto;
}
</style>
</head>
<body>
<div id="controls">
<div id="deviceList">
<div id="gamepadSection" class="device-section">
<h3>Game Controllers</h3>
<div id="gamepadDevices">
<div class="empty">
<div>No gamepads detected</div>
<small>Connect an Xbox, PlayStation, or other gamepad and press any button.</small>
</div>
</div>
</div>
<div id="hidSection" class="device-section">
<h3>Other USB Devices</h3>
<div id="hidDevices">
<div class="empty">
<div>No other devices connected</div>
<small>Click "Connect Device" to add USB game controllers.</small>
</div>
</div>
</div>
</div>
<select id="mappingSelect" style="display: none;">
<option value="">Select controller type</option>
<option value="Xbox">Xbox Controller</option>
<option value="PlayStation">PlayStation Controller</option>
</select>
<button id="connectHID" class="tooltip" data-tooltip="Connect a USB game controller">Connect Device</button>
<button id="disconnectDevice" class="tooltip" data-tooltip="Disconnect selected device" style="display: none;">Disconnect</button>
</div>
<div id="deviceLog"></div>
<svg id="gamepad" viewBox="0 0 800 500">
<g class="controller-xbox active">
<path d="M150 100 Q 300 150, 400 100 T 650 100 C 730 100, 730 250, 730 300 C 730 400, 700 450, 650 450 C 600 450, 450 480, 400 480 C 350 480, 200 450, 150 450 C 100 450, 70 400, 70 300 C 70 250, 70 100, 150 100 Z" fill="#333" stroke="#444" stroke-width="2" class="draggable"/>
<path d="M 150 100 C 70 100, 40 130, 40 200 C 40 300, 70 350, 150 400" fill="#333" stroke="#222" stroke-width="3" class="draggable"/>
<path d="M 650 100 C 730 100, 760 130, 760 200 C 760 300, 730 350, 650 400" fill="#333" stroke="#222" stroke-width="3" class="draggable"/>
<g id="xbox-dpad" transform="translate(250,330)">
<path id="dpad-disc" d="M -50 -50 L -50 50 L 50 50 L 50 -50 Z" fill="none" class="draggable"/>
<path d="M -30 -10 L -10 -10 L -10 -30 L 10 -30 L 10 -10 L 30 -10 L 30 10 L 10 10 L 10 30 L -10 30 L -10 10 L -30 10 Z" fill="#222" stroke="#111" stroke-width="2" class="draggable"/>
<rect id="dpad-up" class="button draggable" x="-15" y="-45" width="30" height="20" rx="3" fill="none" stroke="none"/>
<rect id="dpad-down" class="button draggable" x="-15" y="25" width="30" height="20" rx="3" fill="none" stroke="none"/>
<rect id="dpad-left" class="button draggable" x="-45" y="-15" width="20" height="30" rx="3" fill="none" stroke="none"/>
<rect id="dpad-right" class="button draggable" x="25" y="-15" width="20" height="30" rx="3" fill="none" stroke="none"/>
</g>
<g id="xbox-face" transform="translate(550,240)">
<circle id="button-a" class="button draggable" cx="0" cy="60" r="25" fill="#0a0" stroke="#070" stroke-width="2"/>
<circle id="button-b" class="button draggable" cx="60" cy="0" r="25" fill="#a00" stroke="#700" stroke-width="2"/>
<circle id="button-x" class="button draggable" cx="-60" cy="0" r="25" fill="#00a" stroke="#007" stroke-width="2"/>
<circle id="button-y" class="button draggable" cx="0" cy="-60" r="25" fill="#aa0" stroke="#770" stroke-width="2"/>
<text x="0" y="67" fill="#fff" text-anchor="middle" font-size="22" font-weight="bold" font-family="Arial" class="draggable">A</text>
<text x="60" y="7" fill="#fff" text-anchor="middle" font-size="22" font-weight="bold" font-family="Arial" class="draggable">B</text>
<text x="-60" y="7" fill="#fff" text-anchor="middle" font-size="22" font-weight="bold" font-family="Arial" class="draggable">X</text>
<text x="0" y="-53" fill="#fff" text-anchor="middle" font-size="22" font-weight="bold" font-family="Arial" class="draggable">Y</text>
</g>
<circle cx="148.6431121826172" cy="231.15162658691406" r="40" fill="#222" stroke="#111" stroke-width="2" class="draggable"/>
<circle cx="441.5507507324219" cy="328.3466491699219" r="40" fill="#222" stroke="#111" stroke-width="2" class="draggable"/>
<g id="xbox-sticks">
<circle id="stick-left" class="stick draggable" cx="149.55532836914062" cy="232.06381225585938" r="30" fill="#666" stroke="#444" stroke-width="3"/>
<circle id="stick-right" class="stick draggable" cx="442.46295166015625" cy="329.25885009765625" r="30" fill="#666" stroke="#444" stroke-width="3"/>
</g>
<g id="xbox-shoulders">
<rect id="button-lb" class="button draggable" x="95.26795959472656" y="90.06841278076172" width="100" height="25" rx="5" ry="15" fill="#444" stroke="#222" stroke-width="2"/>
<rect id="button-rb" class="button draggable" x="571.892822265625" y="81.85861206054688" width="100" height="25" rx="5" ry="15" fill="#444" stroke="#222" stroke-width="2"/>
</g>
<circle cx="349.8289794921875" cy="183.11287689208984" r="25" fill="#107c10" stroke="#fff" stroke-width="3" class="draggable"/>
<text x="348.00457763671875" y="193.84947967529297" fill="#fff" text-anchor="middle" font-size="32" font-weight="bold" font-family="Arial" class="draggable">X</text>
</g>
<g class="controller-playstation">
<!-- PS Base -->
<path d="M150 100 C150 50, 650 50, 650 100 L650 400 C650 450, 150 450, 150 400 Z" fill="#333" stroke="#444" stroke-width="2" class="draggable"/>
<!-- D-pad -->
<g id="ps-dpad" transform="translate(230,220)">
<!-- Keep the existing paths but update positions to match Xbox layout -->
<path id="ps-dpad-up" class="button draggable" d="M-15 -45 L15 -45 L15 -25 L-15 -25 Z" fill="#666"/>
<path id="ps-dpad-right" class="button draggable" d="M25 -15 L45 -15 L45 15 L25 15 Z" fill="#666"/>
<path id="ps-dpad-down" class="button draggable" d="M-15 25 L15 25 L15 45 L-15 45 Z" fill="#666"/>
<path id="ps-dpad-left" class="button draggable" d="M-45 -15 L-25 -15 L-25 15 L-45 15 Z" fill="#666"/>
</g>
<!-- Face buttons -->
<g id="ps-face" transform="translate(540,230)">
<!-- Update circle positions to match Xbox spacing -->
<circle id="ps-cross" class="button draggable" cx="0" cy="60" r="25" fill="#666"/>
<circle id="ps-circle" class="button draggable" cx="60" cy="0" r="25" fill="#666"/>
<circle id="ps-square" class="button draggable" cx="-60" cy="0" r="25" fill="#666"/>
<circle id="ps-triangle" class="button draggable" cx="0" cy="-60" r="25" fill="#666"/>
<!-- Update symbol positions to match new circle positions -->
<path d="M-8 60 L8 60 M0 52 L0 68" stroke="#444" stroke-width="3" class="draggable"/>
<circle cx="60" cy="0" r="15" stroke="#444" stroke-width="3" fill="none" class="draggable"/>
<rect x="-70" y="-10" width="20" height="20" stroke="#444" stroke-width="3" fill="none" class="draggable"/>
<path d="M0 -68 L-8 -52 L8 -52 Z" fill="#444" class="draggable"/>
</g>
<!-- Sticks base rings -->
<circle cx="297.3318176269531" cy="348.8255157470703" r="40" fill="#444" stroke="#555" stroke-width="2" class="draggable"/>
<circle cx="462.5313720703125" cy="356.6248779296875" r="40" fill="#444" stroke="#555" stroke-width="2" class="draggable"/>
<!-- Sticks -->
<g id="ps-sticks">
<circle id="ps-stick-left" class="stick draggable" cx="297.3318176269531" cy="348.82554626464844" r="30" fill="#888"/>
<circle id="ps-stick-right" class="stick draggable" cx="460.70697021484375" cy="357.53704833984375" r="30" fill="#888"/>
</g>
<!-- Shoulder buttons -->
<g id="ps-shoulders">
<rect id="ps-l1" class="button draggable" x="200" y="50" width="80" height="30" rx="15" fill="#666"/>
<rect id="ps-r1" class="button draggable" x="520" y="50" width="80" height="30" rx="15" fill="#666"/>
</g>
</g>
</svg>
<script>
const deviceSelect = document.getElementById('deviceSelect');
const connectButton = document.getElementById('connectHID');
const disconnectButton = document.getElementById('disconnectDevice');
const deviceLog = document.getElementById('deviceLog');
let animationFrame;
const devices = new Map();
let lastState = new Uint8Array();
let selectedDeviceId = null;
const mappingSelect = document.getElementById('mappingSelect');
let currentMapping = null;
function logDevice(message) {
const line = document.createElement('div');
line.textContent = `${new Date().toLocaleTimeString()}: ${message}`;
deviceLog.insertBefore(line, deviceLog.firstChild);
if (deviceLog.children.length > 50) deviceLog.lastChild.remove();
}
function updateDeviceOption(deviceId, deviceInfo) {
const deviceEl = document.createElement('div');
deviceEl.className = 'device-option';
const contentWrapper = document.createElement('div');
contentWrapper.style.flex = 1;
const nameEl = document.createElement('div');
nameEl.textContent = deviceId;
contentWrapper.appendChild(nameEl);
if (deviceInfo.detectedType) {
const typeEl = document.createElement('div');
typeEl.className = 'device-info';
typeEl.textContent = `Type: ${deviceInfo.detectedType}`;
contentWrapper.appendChild(typeEl);
}
deviceEl.appendChild(contentWrapper);
const indicator = document.createElement('div');
indicator.className = 'status-indicator';
if (deviceInfo.type === 'gamepad') {
const gamepad = navigator.getGamepads()[deviceInfo.device.index];
indicator.classList.toggle('connected', gamepad && gamepad.connected);
} else {
indicator.classList.toggle('connected', deviceInfo.device.opened);
}
deviceEl.appendChild(indicator);
deviceEl.onclick = () => selectDevice(deviceId);
return deviceEl;
}
function handleHIDInput(e) {
const data = new Uint8Array(e.data.buffer);
if (lastState.length) {
for (let i = 0; i < data.length; i++) {
if (data[i] !== lastState[i]) {
logDevice(`Device ${e.device.productName} Button ${i + 1} ${data[i] ? "Pressed" : "Released"}`);
const buttonMap = {
0: 'button-x',
1: 'button-y',
2: 'button-b',
3: 'button-a',
4: 'dpad-up',
5: 'dpad-right',
6: 'dpad-down',
7: 'dpad-left'
};
const elementId = buttonMap[i];
if (elementId) {
const element = document.getElementById(elementId);
if (element) {
element.classList.toggle('pressed', Boolean(data[i]));
}
}
}
}
}
lastState = data;
}
async function setupHIDDevice(device) {
try {
if (!device.opened) {
await device.open();
}
addDevice(device.productName, device, 'hid');
device.addEventListener("inputreport", handleHIDInput);
logDevice(`Connected to ${device.productName}`);
disconnectButton.style.display = 'inline-block';
} catch (error) {
logDevice(`Error setting up device: ${error.message}`);
}
}
connectButton.onclick = async () => {
try {
const devices = await navigator.hid.requestDevice({
filters: [] // Let Chrome show all HID devices
});
for (const device of devices) {
await setupHIDDevice(device);
}
} catch (error) {
logDevice(`Error connecting device: ${error.message}`);
}
};
const controllerMappings = {
'Xbox': {
type: 'gamepad',
buttons: {
0: 'button-a',
1: 'button-b',
2: 'button-x',
3: 'button-y',
4: 'button-lb',
5: 'button-rb',
12: 'dpad-up',
13: 'dpad-down',
14: 'dpad-left',
15: 'dpad-right'
},
sticks: {
leftX: 0,
leftY: 1,
rightX: 2,
rightY: 3
},
detect: (id) => /xbox|xinput/i.test(id)
},
'PlayStation': {
type: 'gamepad',
buttons: {
0: 'ps-cross',
1: 'ps-circle',
2: 'ps-square',
3: 'ps-triangle',
4: 'ps-l1',
5: 'ps-r1',
12: 'ps-dpad-up',
13: 'ps-dpad-down',
14: 'ps-dpad-left',
15: 'ps-dpad-right'
},
sticks: {
leftX: 0,
leftY: 1,
rightX: 2,
rightY: 3
},
detect: (id) => /playstation|ps4|ps5|dualshock|dualsense/i.test(id)
}
};
function setControllerType(type) {
const xboxController = document.querySelector('.controller-xbox');
const psController = document.querySelector('.controller-playstation');
if (!xboxController || !psController) return;
xboxController.classList.remove('active');
psController.classList.remove('active');
if (type) {
if (type.toLowerCase() === 'xbox') {
xboxController.classList.add('active');
} else if (type.toLowerCase() === 'playstation') {
psController.classList.add('active');
}
currentMapping = controllerMappings[type];
mappingSelect.value = type;
}
}
function detectControllerType(id) {
for (const [type, mapping] of Object.entries(controllerMappings)) {
if (mapping.detect(id)) return type;
}
return null;
}
function addDevice(id, device, type) {
const detectedType = detectControllerType(id);
devices.set(id, { device, type, detectedType });
const gamepadDevices = document.getElementById('gamepadDevices');
const hidDevices = document.getElementById('hidDevices');
gamepadDevices.innerHTML = '';
hidDevices.innerHTML = '';
let hasGamepads = false;
let hasHID = false;
// Sort and group devices
const deviceArray = Array.from(devices.entries()).sort((a, b) => {
if (a[1].type !== b[1].type) return a[1].type === 'gamepad' ? -1 : 1;
if (!!a[1].detectedType !== !!b[1].detectedType) return a[1].detectedType ? -1 : 1;
return a[0].localeCompare(b[0]);
});
deviceArray.forEach(([deviceId, deviceInfo]) => {
const deviceEl = updateDeviceOption(deviceId, deviceInfo);
if (deviceInfo.type === 'gamepad') {
hasGamepads = true;
gamepadDevices.appendChild(deviceEl);
} else {
hasHID = true;
hidDevices.appendChild(deviceEl);
}
});
// Update empty states
if (!hasGamepads) {
gamepadDevices.innerHTML = `
<div class="empty">
No gamepads detected. Connect a controller and press any button.
<br><small>Supports Xbox, PlayStation, and other standard gamepads.</small>
</div>`;
}
if (!hasHID) {
hidDevices.innerHTML = `
<div class="empty">
No other devices connected.
<br><small>Click "Connect Device" to add USB game controllers.</small>
</div>`;
}
if (!selectedDeviceId && deviceArray.length > 0) {
const firstGamepad = deviceArray.find(([_, info]) => info.type === 'gamepad');
if (firstGamepad) selectDevice(firstGamepad[0]);
}
updateConnectButton();
}
function updateButtonVisuals(elementId, isPressed, value = 1) {
const element = document.getElementById(elementId);
if (!element) return;
element.classList.toggle('pressed', isPressed);
if (isPressed) {
// Add visual pressure feedback
const intensity = Math.min(value * 255, 255);
element.style.fill = `rgb(${76 + intensity * 0.2}, ${175 + intensity * 0.1}, ${80 + intensity * 0.1})`;
} else {
element.style.fill = '#666';
}
}
function updateConnectButton() {
const hasDevices = devices.size > 0;
const anyHIDDevices = Array.from(devices.values()).some(d => d.type === 'hid');
connectButton.classList.toggle('pulse', !hasDevices);
connectButton.textContent = hasDevices ? 'Add Device' : 'Connect Device';
if (anyHIDDevices) {
disconnectButton.style.display = 'inline-block';
} else {
disconnectButton.style.display = 'none';
}
}
function selectDevice(deviceId) {
const deviceInfo = devices.get(deviceId);
if (!deviceInfo) return;
selectedDeviceId = deviceId;
// Update UI
document.querySelectorAll('.device-option').forEach(el =>
el.classList.toggle('active', el.textContent === deviceId)
);
if (deviceInfo.detectedType) {
setControllerType(deviceInfo.detectedType);
mappingSelect.style.display = 'none';
} else {
mappingSelect.style.display = 'inline-block';
}
startPolling();
}
function removeDevice(id) {
devices.delete(id);
if (selectedDeviceId === id) {
selectedDeviceId = null;
stopPolling();
}
}
window.addEventListener('gamepadconnected', (e) => {
logDevice(`Gamepad connected: ${e.gamepad.id}`);
addDevice(e.gamepad.id, e.gamepad, 'gamepad');
});
window.addEventListener('gamepaddisconnected', (e) => {
logDevice(`Gamepad disconnected: ${e.gamepad.id}`);
removeDevice(e.gamepad.id);
});
disconnectButton.onclick = async () => {
const deviceInfo = devices.get(selectedDeviceId);
if (deviceInfo && deviceInfo.type === 'hid') {
await deviceInfo.device.close();
removeDevice(selectedDeviceId);
disconnectButton.style.display = 'none';
logDevice(`Disconnected ${selectedDeviceId}`);
}
};
function startPolling() {
if (!animationFrame) {
animationFrame = requestAnimationFrame(updateVisuals);
}
}
function stopPolling() {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
resetVisuals();
}
function updateVisuals() {
const deviceInfo = devices.get(selectedDeviceId);
if (deviceInfo && deviceInfo.type === 'gamepad') {
updateGamepadVisuals(deviceInfo.device);
}
animationFrame = requestAnimationFrame(updateVisuals);
}
mappingSelect.addEventListener('change', () => {
if (mappingSelect.value) {
setControllerType(mappingSelect.value);
}
});
function updateGamepadVisuals(gamepadId) {
const gamepad = navigator.getGamepads()[gamepadId.index];
if (!gamepad || !currentMapping) return;
gamepad.buttons.forEach((button, index) => {
const elementId = currentMapping.buttons[index];
if (elementId) {
updateButtonVisuals(elementId, button.pressed, button.value);
}
});
const stickIds = currentMapping === controllerMappings['PlayStation'] ?
['ps-stick-left', 'ps-stick-right'] :
['stick-left', 'stick-right'];
updateStick(stickIds[0],
gamepad.axes[currentMapping.sticks.leftX],
gamepad.axes[currentMapping.sticks.leftY]
);
updateStick(stickIds[1],
gamepad.axes[currentMapping.sticks.rightX],
gamepad.axes[currentMapping.sticks.rightY]
);
}
function updateStick(stickId, x, y) {
const stick = document.getElementById(stickId);
const maxOffset = 20;
const transformX = x * maxOffset;
const transformY = y * maxOffset;
stick.style.transform = `translate(${transformX}px, ${transformY}px)`;
}
function resetVisuals() {
document.querySelectorAll('.button').forEach(button => {
button.classList.remove('pressed');
});
document.querySelectorAll('.stick').forEach(stick => {
stick.style.transform = 'translate(0, 0)';
});
}
// Check for any already-connected HID devices
async function initializeHID() {
const devices = await navigator.hid.getDevices();
devices.forEach(device => {
logDevice(`Found HID: ${device.productName}`);
if (device.opened) {
setupHIDDevice(device);
}
});
}
document.addEventListener('DOMContentLoaded', initializeHID);
</script>
</body>
</html>

964
examples/googleai.html Normal file
View File

@@ -0,0 +1,964 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemini Vision Chat - Live AI Video Conversations</title>
<meta name="description" content="Experience real-time AI video conversations with Google's Gemini Vision AI. This interactive demo showcases live video analysis and natural language processing capabilities.">
<meta name="keywords" content="Gemini AI, video chat, AI assistant, Google AI, computer vision, real-time AI">
<meta name="robots" content="index, follow">
<meta property="og:title" content="Gemini Vision Chat">
<meta property="og:description" content="Live video conversations with Google's Gemini Vision AI">
<meta property="og:type" content="website">
<meta name="author" content="Steve Seguin">
<link rel="me" href="https://github.com/steveseguin">
<meta property="article:author" content="https://github.com/steveseguin">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnMSIgeDE9IjAlIiB5MT0iMCUiIHgyPSIxMDAlIiB5Mj0iMTAwJSI+PHN0b3Agb2Zmc2V0PSIwJSIgc3R5bGU9InN0b3AtY29sb3I6IzQwNEVFRCIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6IzU4NjVGMiIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxwYXRoIGQ9Ik04IDhoNDh2MzhIMjJMOCA1NlY4eiIgZmlsbD0idXJsKCNnMSkiLz48cGF0aCBkPSJNMjAgMjhoMjRNMjAgMjBoMjRNMjAgMzZoMTYiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48Y2lyY2xlIGN4PSI0OCIgY3k9IjM2IiByPSIzIiBmaWxsPSIjZmZmIi8+PC9zdmc+">
<style>
body {
margin: 0;
padding: 20px;
display: flex;
height: 100vh;
box-sizing: border-box;
font-family: system-ui, -apple-system, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
position: relative;
}
.github-link {
position: fixed;
bottom: 15px;
left: 15px;
opacity: 0.7;
transition: opacity 0.2s;
}
.github-link:hover {
opacity: 1;
}
p {
display: inline-block;
}
.left-panel {
width: 50%;
padding-right: 20px;
}
.right-panel {
width: 50%;
display: flex;
flex-direction: column;
height: 100%;
}
.controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.preview {
width: 100%;
max-height: calc(100vh - 200px);
object-fit: contain;
border-radius: 12px;
background: #2a2a2a;
}
#error {
color: #ff6b6b;
margin: 10px 0;
}
select, button, .api-key, .message-input {
background: #2a2a2a;
border: 1px solid #404040;
color: #e0e0e0;
padding: 8px 12px;
border-radius: 8px;
font-size: 14px;
transition: all 0.2s ease;
}
select:hover, button:hover {
background: #333;
border-color: #505050;
}
button {
cursor: pointer;
background: #404eed;
border: none;
font-weight: 500;
}
button:hover {
background: #5865f2;
}
#startButton {
background: #22c55e;
font-size: 16px;
padding: 10px 20px;
font-weight: 600;
animation: pulse 2s infinite;
}
#startButton:hover {
background: #16a34a;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.api-key.highlight {
border-color: #ff6b6b;
outline: none;
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.3);
}
.api-key-container {
display: flex;
flex-direction: row;
gap: 8px;
}
.api-key-info {
font-size: 13px;
color: #a0a0a0;
margin: auto;
}
.api-key-info a {
color: #5865f2;
text-decoration: none;
}
.api-key-info a:hover {
text-decoration: underline;
}
#startButton:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #2a2a2a;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
background: #2a2a2a;
border-radius: 12px;
overflow: hidden;
}
.responses {
flex-grow: 1;
padding: 16px;
background: #2a2a2a;
overflow-y: auto;
margin-bottom: 10px;
}
.input-container {
display: flex;
gap: 10px;
padding: 16px;
background: #232323;
border-top: 1px solid #404040;
}
.message {
margin: 8px 0;
padding: 12px;
border-radius: 8px;
line-height: 1.5;
}
.user-message {
background: #404eed;
margin-left: 20px;
color: #fff;
}
.assistant-message {
background: #333;
margin-right: 20px;
}
.markdown-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.markdown-content li {
margin-left: 20px;
margin-bottom: 5px;
}
.markdown-content code {
background: #232323;
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, monospace;
font-size: 0.9em;
}
.responses::-webkit-scrollbar {
width: 8px;
}
.responses::-webkit-scrollbar-track {
background: #232323;
border-radius: 4px;
}
.responses::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
.responses::-webkit-scrollbar-thumb:hover {
background: #505050;
}
</style>
</head>
<body>
<div class="left-panel">
<div class="controls">
<select id="videoSource"></select>
<select id="audioSource"></select>
<button id="startButton">Start Stream</button>
<select id="responseType">
<option value="text">Text Response</option>
<option value="audio">Audio Response</option>
</select>
<select id="voiceSelect" style="display: none;">
<option value="Aoede">Female Voice 1 (Aoede)</option>
<option value="Kore">Female Voice 2 (Kore)</option>
<option value="Puck">Male Voice 1 (Puck)</option>
<option value="Charon">Male Voice 2 (Charon)</option>
<option value="Fenrir">Male Voice 3 (Fenrir)</option>
</select>
<div class="api-key-container">
<input type="password" id="apiKey" placeholder="Enter Gemini API Key" size="15" class="api-key">
<div class="api-key-info">
Get your free Gemini API key at <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener">Google AI Studio</a>.
</div>
</div> </div>
<div id="error"></div>
<video class="preview" id="preview" autoplay muted></video>
</div>
<div class="right-panel">
<div class="chat-container">
<div id="responses" class="responses"></div>
<div class="input-container">
<input type="text" class="message-input" placeholder="Type a message...">
<button id="sendButton">Send</button>
</div>
</div>
</div>
<a href="https://github.com/steveseguin/gemini-chatbot" class="github-link" target="_blank" rel="noopener noreferrer" title="Fork on GitHub (MIT License)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="#e0e0e0">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
<script>
class GoogleLivePublisher {
constructor(stream, apiKey) {
this.stream = stream;
this.apiKey = apiKey;
this.ws = null;
this.audioContext = null;
this.videoProcessor = null;
this.canvasContext = null;
this.lastImageTime = 0;
this.imageInterval = 200;
this.imageWidth = 640;
this.imageHeight = 360;
this.handleMessage = this.handleMessage.bind(this);
this.audioPlayer = new AudioPlayer();
}
async handleMessage(event) {
try {
let response;
if (event.data instanceof Blob) {
const text = await event.data.text();
response = JSON.parse(text);
} else {
response = JSON.parse(event.data);
}
if (response.setupComplete) {
console.log('Setup complete received');
this.sendPrompt("Hi, introduce yourself in a sentence for me. Be friendly to me.");
}
if (response.serverContent?.modelTurn?.parts) {
const parts = response.serverContent.modelTurn.parts;
let hasAudioParts = false;
parts.forEach(part => {
if (part.text) {
console.log('Model response:', part.text);
const event = new CustomEvent('modelResponse', {
detail: {
text: part.text
}
});
window.dispatchEvent(event);
}
if (part.inlineData && part.inlineData.mimeType.startsWith('audio/')) {
hasAudioParts = true;
console.log('Received audio response with mime type:', part.inlineData.mimeType);
try {
const rateMatch = part.inlineData.mimeType.match(/rate=(\d+)/);
const sampleRate = rateMatch ? parseInt(rateMatch[1]) : 24000;
this.audioPlayer.resume();
const audioData = base64ToArrayBuffer(part.inlineData.data);
console.log('Processing audio chunk of size:', audioData.byteLength);
this.audioPlayer.addPCM16(new Uint8Array(audioData));
} catch (err) {
console.error('Error processing audio:', err);
}
}
});
if (response.serverContent.turnComplete && hasAudioParts) {
console.log('Turn complete, finalizing audio');
this.audioPlayer.complete();
}
}
if (!response.setupComplete && !response.serverContent) {
console.log('Other response type:', response);
}
} catch (err) {
console.error('Error handling message:', err);
}
}
sendPrompt(text) {
if (!this.isConnected()) {
console.error('WebSocket not connected, attempting reconnect...');
this.connect().then(() => {
this._sendPromptInternal(text);
});
return;
}
this._sendPromptInternal(text);
}
_sendPromptInternal(text) {
if (this.isConnected()) {
const message = {
clientContent: {
turns: [{
role: "user",
parts: [{
text
}]
}],
turnComplete: true
}
};
console.log('Sending prompt:', message);
this.ws.send(JSON.stringify(message));
} else {
console.error('WebSocket still not ready after reconnect attempt');
}
}
sendMediaChunk(mediaChunks) {
if (this.ws?.readyState === WebSocket.OPEN) {
const message = {
realtimeInput: {
mediaChunks: mediaChunks.map(chunk => ({
mimeType: chunk.inlineData.mimeType,
data: chunk.inlineData.data
}))
}
};
this.ws.send(JSON.stringify(message));
}
}
isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
async connect() {
const host = 'generativelanguage.googleapis.com';
const uri = `wss://${host}/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent?key=${this.apiKey}`;
if (this.isConnected()) {
console.log('Already connected');
return;
}
const responseType = document.getElementById('responseType');
const voiceSelect = document.getElementById('voiceSelect');
voiceSelect.style.display = responseType.value === 'audio' ? 'block' : 'none';
this.ws = new WebSocket(uri);
this.ws.onmessage = this.handleMessage;
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
};
await new Promise((resolve, reject) => {
this.ws.addEventListener('open', resolve, {
once: true
});
this.ws.addEventListener('error', reject, {
once: true
});
});
const setupMessage = {
setup: {
model: "models/gemini-2.0-flash-exp",
systemInstruction: {
parts: [{
text: "You are a friendly and helpful social chat assistant that can see and hear the user."
}]
},
generationConfig: {
temperature: 0.9,
topK: 1,
topP: 1,
candidateCount: 1,
responseModalities: responseType.value === 'audio' ? 'AUDIO' : 'TEXT',
...(responseType.value === 'audio' && {
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: voiceSelect.value
}
}
}
})
}
}
};
console.log('Sending setup message:', setupMessage);
this.ws.send(JSON.stringify(setupMessage));
}
async start() {
try {
await this.connect();
await this.setupAudioProcessing();
this.setupVideoProcessing();
} catch (err) {
console.error('Failed to start:', err);
this.stop();
throw err;
}
}
async setupAudioProcessing() {
this.audioContext = new AudioContext({
sampleRate: 16000
});
const workletBlob = new Blob([`registerProcessor('audio-processor', ${AudioProcessingWorklet})`], {
type: 'application/javascript'
});
const workletUrl = URL.createObjectURL(workletBlob);
await this.audioContext.audioWorklet.addModule(workletUrl);
URL.revokeObjectURL(workletUrl);
const source = this.audioContext.createMediaStreamSource(this.stream);
const processor = new AudioWorkletNode(this.audioContext, 'audio-processor');
processor.port.onmessage = (event) => {
if (event.data.data?.int16arrayBuffer) {
const base64Audio = btoa(String.fromCharCode(...new Uint8Array(event.data.data.int16arrayBuffer)));
this.sendMediaChunk([{
mime_type: "audio/pcm;rate=16000",
data: base64Audio
}]);
}
};
source.connect(processor);
}
setupVideoProcessing() {
const canvas = document.createElement('canvas');
canvas.width = this.imageWidth;
canvas.height = this.imageHeight;
this.canvasContext = canvas.getContext('2d');
const videoTrack = this.stream.getVideoTracks()[0];
const videoElement = document.createElement('video');
videoElement.srcObject = new MediaStream([videoTrack]);
videoElement.autoplay = true;
const captureFrame = () => {
const now = Date.now();
if (now - this.lastImageTime >= this.imageInterval) {
this.canvasContext.drawImage(videoElement, 0, 0, this.imageWidth, this.imageHeight);
const base64Image = canvas.toDataURL('image/jpeg', 0.8).split(',')[1];
this.sendMediaChunk([{
mime_type: "image/jpeg",
data: base64Image
}]);
this.lastImageTime = now;
}
if (!this.stopped) {
requestAnimationFrame(captureFrame);
}
};
videoElement.addEventListener('loadedmetadata', () => {
requestAnimationFrame(captureFrame);
});
}
sendMediaChunk(mediaChunks) {
if (this.ws?.readyState === WebSocket.OPEN) {
const message = {
realtimeInput: {
mediaChunks
}
};
this.ws.send(JSON.stringify(message));
}
}
stop() {
this.stopped = true;
this.ws?.close();
this.audioContext?.close();
this.audioPlayer?.stop();
this.ws = null;
this.audioContext = null;
this.videoProcessor = null;
this.canvasContext = null;
}
}
class AudioPlayer {
constructor() {
this.context = new AudioContext();
this.gainNode = this.context.createGain();
this.gainNode.connect(this.context.destination);
this.gainNode.gain.value = 1;
this.bufferSize = 8192 * 4;
this.sampleRate = 24000;
this.processingBuffer = new Float32Array(0);
this.audioQueue = [];
this.isPlaying = false;
this.scheduledTime = 0;
this.currentSource = null;
this.silencePadding = 0.015;
this.startDelay = 0.05;
this.bufferTarget = 3;
this.scheduleAheadTime = 0.2;
this.minimumBufferSize = this.bufferSize;
this.underrunRecoveryTime = 0.2;
this.maxBufferSize = this.bufferSize * 8;
this.isPaused = false;
this.lastPlaybackTime = 0;
this.totalScheduledDuration = 0;
this.underrunCount = 0;
this.lastUnderrunTime = 0;
this.adaptiveBufferTarget = this.bufferTarget;
}
addPCM16(chunk) {
const float32Array = new Float32Array(chunk.length / 2);
const dataView = new DataView(chunk.buffer);
for (let i = 0; i < chunk.length / 2; i++) {
float32Array[i] = dataView.getInt16(i * 2, true) / 32768;
}
const newBuffer = new Float32Array(this.processingBuffer.length + float32Array.length);
newBuffer.set(this.processingBuffer);
newBuffer.set(float32Array, this.processingBuffer.length);
this.processingBuffer = newBuffer;
if (this.processingBuffer.length >= this.minimumBufferSize) {
const paddedBuffer = this.addSilencePadding(this.processingBuffer);
this.audioQueue.push(paddedBuffer);
this.processingBuffer = new Float32Array(0);
if (!this.isPlaying && this.audioQueue.length >= this.adaptiveBufferTarget) {
this.isPlaying = true;
this.scheduledTime = this.context.currentTime + (this.initialChunk ? this.startDelay : 0);
this.initialChunk = false;
this.scheduleNextBuffer();
}
}
}
addSilencePadding(audioData) {
const paddingSamples = Math.floor(this.silencePadding * this.sampleRate);
const crossfadeSamples = Math.min(paddingSamples, Math.floor(this.sampleRate * 0.015));
const paddedBuffer = new Float32Array(audioData.length + (paddingSamples * 2));
paddedBuffer.set(audioData, paddingSamples);
for (let i = 0; i < crossfadeSamples; i++) {
const fadeIn = 0.5 * (1 - Math.cos((i / crossfadeSamples) * Math.PI));
paddedBuffer[paddingSamples + i] *= fadeIn;
}
for (let i = 0; i < crossfadeSamples; i++) {
const fadeOut = 0.5 * (1 + Math.cos((i / crossfadeSamples) * Math.PI));
paddedBuffer[paddingSamples + audioData.length - crossfadeSamples + i] *= fadeOut;
}
return paddedBuffer;
}
scheduleNextBuffer() {
if (!this.isPlaying || this.isPaused) return;
const now = this.context.currentTime;
const buffersNeeded = Math.max(0, this.adaptiveBufferTarget - this.audioQueue.length);
if (this.audioQueue.length === 0) {
this.underrunCount++;
this.lastUnderrunTime = Date.now();
this.isPaused = true;
this.lastPlaybackTime = this.scheduledTime;
return;
}
while (this.audioQueue.length > 0 &&
this.scheduledTime < now + this.scheduleAheadTime) {
const audioData = this.audioQueue.shift();
const audioBuffer = this.createAudioBuffer(audioData);
const source = this.context.createBufferSource();
source.buffer = audioBuffer;
const startTime = Math.max(this.scheduledTime, now);
source.connect(this.gainNode);
const scheduleOffset = 0.005;
source.start(startTime + scheduleOffset);
this.currentSource = source;
this.scheduledTime = startTime + audioBuffer.duration - this.silencePadding;
source.onended = () => {
if (this.audioQueue.length > 0) {
requestAnimationFrame(() => this.scheduleNextBuffer());
}
};
}
if (this.isPlaying && !this.isPaused) {
const nextCheckDelay = Math.max(10,
(this.scheduledTime - this.context.currentTime) * 500
);
setTimeout(() => this.scheduleNextBuffer(), nextCheckDelay);
}
}
createAudioBuffer(audioData) {
const audioBuffer = this.context.createBuffer(1, audioData.length, this.sampleRate);
audioBuffer.getChannelData(0).set(audioData);
return audioBuffer;
}
stop() {
this.complete();
setTimeout(() => {
this.isPlaying = false;
this.isPaused = false;
if (this.currentSource) {
try {
this.currentSource.stop();
} catch (e) {
console.warn('Error stopping current source:', e);
}
this.currentSource = null;
}
this.audioQueue = [];
this.processingBuffer = new Float32Array(0);
this.underrunCount = 0;
this.lastUnderrunTime = 0;
this.adaptiveBufferTarget = this.bufferTarget;
this.initialChunk = true;
this.totalScheduledDuration = 0;
this.lastPlaybackTime = 0;
const currentTime = this.context.currentTime;
this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, currentTime);
this.gainNode.gain.linearRampToValueAtTime(0, currentTime + 0.2);
setTimeout(() => {
this.gainNode.disconnect();
this.gainNode = this.context.createGain();
this.gainNode.connect(this.context.destination);
}, 300);
}, 500);
}
complete() {
if (this.processingBuffer.length > 0) {
const paddedBuffer = this.addSilencePadding(this.processingBuffer);
this.audioQueue.push(paddedBuffer);
this.processingBuffer = new Float32Array(0);
}
const endingSilence = new Float32Array(Math.floor(this.sampleRate * 0.2));
this.audioQueue.push(endingSilence);
if (this.isPlaying) {
this.scheduleNextBuffer();
} else if (this.audioQueue.length > 0) {
this.isPlaying = true;
this.scheduledTime = this.context.currentTime + 0.05;
this.scheduleNextBuffer();
}
}
async resume() {
if (this.context.state === "suspended") {
await this.context.resume();
}
this.gainNode.gain.setValueAtTime(0, this.context.currentTime);
this.gainNode.gain.linearRampToValueAtTime(1, this.context.currentTime + 0.1);
}
}
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
class MessageFormatter {
constructor() {
this.currentMessage = '';
this.currentMessageElement = null;
this.messageBuffer = '';
this.messageComplete = false;
this.lastMessageTime = Date.now();
this.pauseThreshold = 300;
}
formatMarkdown(text) {
let formatted = text
.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>');
const lines = formatted.split('\n');
const formattedLines = lines.map(line => {
if (line.trim().startsWith('*') && line.trim()[1] === ' ') {
return `<li>${line.trim().substring(2)}</li>`;
}
if (/^\d+\./.test(line.trim())) {
return `<li>${line.trim()}</li>`;
}
return line;
});
return formattedLines.join('\n')
.replace(/\n\n/g, '<br><br>')
.replace(/\n(?![<])/g, '<br>');
}
appendMessage(text, isUser = false) {
const now = Date.now();
if (isUser) {
const messageDiv = document.createElement('div');
messageDiv.className = 'message user-message';
const contentDiv = document.createElement('div');
contentDiv.className = 'markdown-content';
contentDiv.textContent = text;
messageDiv.appendChild(contentDiv);
responsesDiv.appendChild(messageDiv);
this.messageComplete = true;
this.scrollToBottom();
this.lastMessageTime = now;
return;
}
if (this.currentMessageElement && (now - this.lastMessageTime > this.pauseThreshold)) {
this.messageBuffer += '\n';
}
this.messageBuffer += text;
this.lastMessageTime = now;
if (!this.currentMessageElement) {
this.currentMessageElement = document.createElement('div');
this.currentMessageElement.className = 'message assistant-message';
const contentDiv = document.createElement('div');
contentDiv.className = 'markdown-content';
this.currentMessageElement.appendChild(contentDiv);
responsesDiv.appendChild(this.currentMessageElement);
}
const contentDiv = this.currentMessageElement.querySelector('.markdown-content');
contentDiv.innerHTML = this.formatMarkdown(this.messageBuffer);
if (
this.messageBuffer.match(/\n\n$/) ||
this.messageBuffer.match(/[.!?]\s+$/) ||
this.messageBuffer.match(/\n\s*[-*]\s.*\n\n$/)
) {
this.finalizeMessage();
}
this.scrollToBottom();
}
finalizeMessage() {
this.messageBuffer = '';
this.currentMessageElement = null;
this.messageComplete = true;
this.lastMessageTime = Date.now();
}
scrollToBottom() {
responsesDiv.scrollTop = responsesDiv.scrollHeight;
}
}
const AudioProcessingWorklet = `
class AudioProcessor extends AudioWorkletProcessor {
buffer = new Int16Array(2048);
bufferWriteIndex = 0;
process(inputs) {
if (inputs[0].length) {
const samples = inputs[0][0];
for (let i = 0; i < samples.length; i++) {
const int16Value = samples[i] * 32768;
this.buffer[this.bufferWriteIndex++] = int16Value;
if(this.bufferWriteIndex >= this.buffer.length) {
this.port.postMessage({
data: { int16arrayBuffer: this.buffer.buffer }
});
this.bufferWriteIndex = 0;
}
}
}
return true;
}
}`;
const messageFormatter = new MessageFormatter();
window.addEventListener('modelResponse', (event) => {
console.log(event.detail.text);
messageFormatter.appendMessage(event.detail.text);
});
let stream = null;
const videoSelect = document.getElementById('videoSource');
const audioSelect = document.getElementById('audioSource');
const preview = document.getElementById('preview');
const errorDisplay = document.getElementById('error');
const responsesDiv = document.getElementById('responses');
let publisher = null;
function validateApiKey() {
const apiKey = document.getElementById('apiKey').value.trim();
startButton.disabled = !apiKey;
return apiKey;
}
document.getElementById('apiKey').value = localStorage.getItem('apiKey') || '';
validateApiKey();
document.getElementById('apiKey').addEventListener('input', validateApiKey);
startButton.addEventListener('click', async () => {
const apiKeyInput = document.getElementById('apiKey');
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
apiKeyInput.classList.add('highlight');
setTimeout(() => apiKeyInput.classList.remove('highlight'), 2000);
return;
}
try {
if (publisher) {
startButton.textContent = 'Starting...';
startButton.disabled = true;
publisher.stop();
publisher = null;
preview.srcObject = null;
startButton.textContent = 'Start Stream';
startButton.disabled = false;
return;
}
startButton.textContent = 'Starting...';
startButton.disabled = true;
const stream = await getStream();
preview.srcObject = stream;
localStorage.setItem('apiKey', apiKey);
publisher = new GoogleLivePublisher(stream, apiKey);
await publisher.start();
startButton.textContent = 'Stop Stream';
startButton.disabled = false;
} catch (err) {
console.error(err);
showError('Failed to start publishing: ' + err.message);
startButton.textContent = 'Start Stream';
startButton.disabled = false;
}
});
async function getDevices() {
try {
await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
})
.then(stream => stream.getTracks().forEach(track => track.stop()))
.catch(e => console.warn('Permission denied:', e));
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(d => d.kind === 'videoinput');
const audioDevices = devices.filter(d => d.kind === 'audioinput');
videoDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.deviceId;
option.text = device.label || `Camera ${videoSelect.length + 1}`;
videoSelect.appendChild(option);
});
audioDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.deviceId;
option.text = device.label || `Microphone ${audioSelect.length + 1}`;
audioSelect.appendChild(option);
});
} catch (err) {
showError('Failed to get devices: ' + err.message);
}
}
async function getStream() {
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
const constraints = {
video: {
deviceId: videoSelect.value ? {
exact: videoSelect.value
} : undefined
},
audio: {
deviceId: audioSelect.value ? {
exact: audioSelect.value
} : undefined
}
};
try {
stream = await navigator.mediaDevices.getUserMedia(constraints);
preview.srcObject = stream;
return stream;
} catch (err) {
showError('Failed to get stream: ' + err.message);
throw err;
}
}
function showError(message) {
errorDisplay.textContent = message;
}
if (!navigator.mediaDevices?.getUserMedia) {
showError('getUserMedia not supported');
} else {
navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
.then(initialStream => {
initialStream.getTracks().forEach(track => track.stop());
getDevices();
})
.catch(err => showError('Initial permission request failed: ' + err.message));
navigator.mediaDevices.addEventListener('devicechange', getDevices);
}
const messageInput = document.querySelector('.message-input');
const sendButton = document.querySelector('#sendButton');
responsesDiv.parentElement.insertBefore(messageInput, responsesDiv);
responsesDiv.parentElement.insertBefore(sendButton, responsesDiv);
sendButton.addEventListener('click', async () => {
if (!publisher) {
showError('Please start the stream first');
return;
}
if (messageInput.value.trim()) {
try {
messageFormatter.appendMessage(messageInput.value, true);
await publisher.sendPrompt(messageInput.value);
messageInput.value = '';
} catch (err) {
console.error('Failed to send message:', err);
showError('Failed to send message: ' + err.message);
}
}
});
document.getElementById('voiceSelect').addEventListener('change', async () => {
if (publisher && startButton.textContent === 'Stop Stream') {
startButton.textContent = 'Starting...';
startButton.disabled = true;
publisher.stop();
publisher = null;
try {
const stream = await getStream();
preview.srcObject = stream;
const apiKey = document.getElementById('apiKey').value;
publisher = new GoogleLivePublisher(stream, apiKey);
await publisher.start();
startButton.textContent = 'Stop Stream';
startButton.disabled = false;
} catch (err) {
console.error(err);
showError('Failed to restart with new voice: ' + err.message);
startButton.textContent = 'Start Stream';
startButton.disabled = false;
}
}
});
document.getElementById('responseType').addEventListener('change', function() {
const voiceSelect = document.getElementById('voiceSelect');
voiceSelect.style.display = this.value === 'audio' ? 'block' : 'none';
if (publisher && startButton.textContent === 'Stop Stream') {
startButton.textContent = 'Starting...';
startButton.disabled = true;
publisher.stop();
publisher = null;
(async () => {
try {
const stream = await getStream();
preview.srcObject = stream;
const apiKey = document.getElementById('apiKey').value;
publisher = new GoogleLivePublisher(stream, apiKey);
await publisher.start();
startButton.textContent = 'Stop Stream';
startButton.disabled = false;
} catch (err) {
console.error(err);
showError('Failed to restart with new response type: ' + err.message);
startButton.textContent = 'Start Stream';
startButton.disabled = false;
}
})();
}
});
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendButton.click();
}
});
</script>
</body>
</html>

45
examples/grid.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic Iframe Addition</title>
<style>
/* Grid layout for iframes */
.iframe-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); /* Adjust based on desired width */
gap: 10px; /* Spacing between iframes */
}
iframe {
width: 100%;
height: 200px; /* Adjust based on desired height */
border: 1px solid #ccc;
}
</style>
</head>
<body>
<h2>Add a URL as Iframe</h2>
<input type="text" id="urlInput" value="https://vdo.ninja/" placeholder="Enter URL here">
<button onclick="addIframe()">Add Iframe</button>
<div class="iframe-container" id="iframeContainer"></div>
<script>
function addIframe() {
var url = document.getElementById('urlInput').value;
if (url) { // Check if the URL is not empty
var iframe = document.createElement('iframe');
iframe.src = url;
iframe.allow = "document-domain;encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;gyroscope;";
document.getElementById('iframeContainer').appendChild(iframe);
//document.getElementById('urlInput').value = ''; // Clear input field
} else {
alert("Please enter a URL.");
}
}
</script>
</body>
</html>

290
examples/httpwssapi.md Normal file
View File

@@ -0,0 +1,290 @@
# VDO.Ninja Remote Control API Documentation
## Overview
VDO.Ninja's Remote Control API allows programmatic control of VDO.Ninja sessions via HTTP or WebSocket connections. This powerful API enables integration with stream decks, custom applications, and automation tools for controlling cameras, microphones, layouts, and other features.
## Basic Setup
To enable the API on any VDO.Ninja instance, add the `&api` parameter with a unique API key:
```
https://vdo.ninja/?api=YOUR_UNIQUE_API_KEY&webcam
```
This key must be kept private and will be used to authenticate API requests. The same key must be used when making API calls to control this specific VDO.Ninja instance.
## Connection Methods
The API supports three connection methods:
1. **WebSocket API** (recommended for real-time control)
2. **HTTP GET API** (good for simple controllers and hotkeys)
3. **Server-Sent Events** (SSE) for one-way event monitoring
### WebSocket API
Connect to `wss://api.vdo.ninja:443` and authenticate with your API key:
```javascript
const socket = new WebSocket("wss://api.vdo.ninja:443");
socket.onopen = function() {
// Join with your API key
socket.send(JSON.stringify({"join": "YOUR_UNIQUE_API_KEY"}));
// After joining, you can send commands
socket.send(JSON.stringify({
"action": "mic",
"value": false // mute microphone
}));
};
// Listen for responses and events
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log("Received:", data);
};
```
### HTTP GET API
Structure: `https://api.vdo.ninja/{apiKey}/{action}/{target}/{value}`
Examples:
```
https://api.vdo.ninja/YOUR_UNIQUE_API_KEY/mic/false // Mute microphone
https://api.vdo.ninja/YOUR_UNIQUE_API_KEY/camera/toggle // Toggle camera
```
### Server-Sent Events (SSE)
For monitoring events without sending commands:
```javascript
const eventSource = new EventSource(`https://api.vdo.ninja/sse/YOUR_UNIQUE_API_KEY`);
eventSource.onmessage = function(event) {
console.log(JSON.parse(event.data));
};
```
## API Commands Reference
### Self-Targeted Commands
These commands affect the local VDO.Ninja instance that has the API key enabled.
| Action | Value Options | Description |
|--------|--------------|-------------|
| `mic` | `true`, `false`, `toggle` | Control microphone state |
| `camera` | `true`, `false`, `toggle` | Control camera state |
| `speaker` | `true`, `false`, `toggle` | Control speaker state |
| `volume` | `0` to `200` | Set playback volume (percentage) |
| `bitrate` | Integer (kbps), `-1` for auto | Set video bitrate |
| `record` | `true`, `false` | Control local recording |
| `hangup` | N/A | Disconnect current session |
| `reload` | N/A | Reload the page |
| `sendChat` | Text string | Send a chat message |
| `togglehand` | N/A | Toggle raised hand status |
| `togglescreenshare` | N/A | Toggle screen sharing |
| `forceKeyframe` | N/A | Force video keyframes ("rainbow puke fix") |
| `getDetails` | N/A | Get detailed state information |
| `getGuestList` | N/A | Get list of connected guests with IDs |
### Layout Control Commands
| Action | Value | Description |
|--------|-------|-------------|
| `layout` | `0` or `false` | Switch to auto-mixer layout |
| `layout` | Integer (`1`, `2`, etc.) | Switch to specific predefined layout |
| `layout` | Layout object/array | Apply custom layout configuration |
### Camera Control (PTZ) Commands
| Action | Value | Description |
|--------|-------|-------------|
| `zoom` | `-1.0` to `1.0` | Adjust zoom level (relative) |
| `zoom` | `0.0` to `1.0` with `value2="abs"` | Set absolute zoom level |
| `focus` | `-1.0` to `1.0` | Adjust focus (relative) |
| `pan` | `-1.0` to `1.0` | Adjust camera pan (negative=left) |
| `tilt` | `-1.0` to `1.0` | Adjust camera tilt (negative=down) |
| `exposure` | `0.0` to `1.0` | Adjust camera exposure |
### Group Communication Commands
| Action | Value | Description |
|--------|-------|-------------|
| `group` | `1` to `8` | Toggle participation in specified group |
| `joinGroup` | `1` to `8` | Join a specific group |
| `leaveGroup` | `1` to `8` | Leave a specific group |
| `viewGroup` | `1` to `8` | Toggle view of specified group |
| `joinViewGroup` | `1` to `8` | View a specific group |
| `leaveViewGroup` | `1` to `8` | Stop viewing a specific group |
### Timer Commands
| Action | Value | Description |
|--------|-------|-------------|
| `startRoomTimer` | Integer (seconds) | Start countdown timer for room |
| `pauseRoomTimer` | N/A | Pause the room timer |
| `stopRoomTimer` | N/A | Stop and reset the room timer |
### Presentation Control
| Action | Value | Description |
|--------|-------|-------------|
| `nextSlide` | N/A | Advance to next slide (for PowerPoint integration) |
| `prevSlide` | N/A | Go to previous slide |
| `soloVideo` | `true`, `false`, `toggle` | Highlight video for all guests |
### Director-Only Guest Commands
These commands target specific guests when you are the director.
| Action | Target | Value | Description |
|--------|--------|-------|-------------|
| `forward` | Guest ID/slot | Room name | Transfer guest to another room |
| `addScene` | Guest ID/slot | Scene ID (1-8) | Toggle guest in/out of scene |
| `muteScene` | Guest ID/slot | Scene ID | Toggle guest's audio in scene |
| `mic` | Guest ID/slot | `true`, `false`, `toggle` | Control guest's microphone |
| `hangup` | Guest ID/slot | N/A | Disconnect a specific guest |
| `soloChat` | Guest ID/slot | N/A | Private chat with guest |
| `soloChatBidirectional` | Guest ID/slot | N/A | Two-way private chat |
| `speaker` | Guest ID/slot | N/A | Toggle guest's speaker |
| `display` | Guest ID/slot | N/A | Toggle guest's display |
| `forceKeyframe` | Guest ID/slot | N/A | Fix video artifacts for guest |
| `soloVideo` | Guest ID/slot | N/A | Highlight specific guest's video |
| `volume` | Guest ID/slot | `0` to `200` | Set guest's microphone volume |
| `mixorder` | Guest ID/slot | `-1` or `1` | Change guest's position in mixer |
## Target Parameter Explanation
When using director commands, you can specify targets in two ways:
1. **Slot number**: Simple integers like `1`, `2`, `3` (corresponds to position in room)
2. **Stream ID**: The unique ID for a specific guest (more reliable as slots can change)
Examples:
```javascript
// Target guest in slot 1
{"action": "mic", "target": 1, "value": false}
// Target guest with specific stream ID
{"action": "mic", "target": "abc123xyz", "value": false}
```
## Callbacks and Responses
API commands receive callbacks with the current state after execution:
```javascript
// WebSocket example response when toggling mic
{
"callback": {
"action": "mic",
"value": "toggle",
"result": false // Indicates mic is now muted
}
}
```
## Custom Layout Format
The layout API supports complex scene configurations. Layouts can be arrays of objects with properties:
```javascript
// Simple layout with two videos
{
"action": "layout",
"value": [
{"x": 0, "y": 0, "w": 50, "h": 100, "slot": 0},
{"x": 50, "y": 0, "w": 50, "h": 100, "slot": 1}
]
}
```
Layout object properties:
- `x`, `y`: Position (percentage of canvas)
- `w`, `h`: Width and height (percentage)
- `slot`: Which video slot to display (0-indexed)
- `z`: Z-index for layering (optional)
- `c`: Cover mode (true/false, optional)
## Implementation Examples
### Python Example
```python
import websockets
import asyncio
import json
async def control_camera():
async with websockets.connect("wss://api.vdo.ninja:443") as websocket:
# Join with API key
await websocket.send(json.dumps({"join": "YOUR_API_KEY"}))
# Zoom in camera
await websocket.send(json.dumps({
"action": "zoom",
"value": 0.5,
"value2": "abs"
}))
# Wait for response
response = await websocket.recv()
print(f"Response: {response}")
asyncio.run(control_camera())
```
### JavaScript HTTP Example
```javascript
// Toggle microphone via HTTP
fetch("https://api.vdo.ninja/YOUR_API_KEY/mic/toggle")
.then(response => response.text())
.then(result => console.log("Mic toggled, new state:", result));
```
## Integration with Automation Tools
The API integrates well with:
1. **BitFocus Companion**: Official module available at [github.com/bitfocus/companion-module-vdo-ninja](https://github.com/bitfocus/companion-module-vdo-ninja)
2. **Stream Deck**: Can use HTTP requests for button actions
3. **Node-RED**: Great for complex automation workflows
4. **Home Assistant**: For smart home integration
## Security Considerations
- Keep your API key private
- Consider using unique keys for different productions
- The API has full control over the VDO.Ninja instance it's connected to
- All connections are encrypted over SSL/TLS
## Troubleshooting
- Ensure the API key matches exactly between VDO.Ninja and your requests
- For WebSocket connections, implement reconnection logic (connections timeout after ~1 minute of inactivity)
- When using HTTP API, a `timeout` response means the request couldn't reach the target
## Additional Resources
- Complete API documentation: [github.com/steveseguin/Companion-Ninja](https://github.com/steveseguin/Companion-Ninja)
- Interactive demo: [companion.vdo.ninja](https://companion.vdo.ninja)
- For Python implementations: See the Python sample in the repository
## Advanced Usage: Self-Hosting the API
For production environments, you can self-host the API server:
1. Clone the repository from GitHub
2. Install dependencies with `npm install`
3. Modify the server URL in your VDO.Ninja instances:
```javascript
session.apiserver = "wss://your-custom-domain:443";
```
4. Run the server with proper SSL certificates
Note: Self-hosting support is limited and should only be attempted by experienced developers.

View File

@@ -0,0 +1,129 @@
<html>
<head><title>Basic IFRAME Inbound Stats sample</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
color:white;
}
iframe {
width:60%;
height:60%;
border:0;
margin:0;
padding:0;
display:block;
}
input{
padding:10px;
width:80%;
font-size:1.2em;
z-index: 1000;
margin:10%;
}
#startButton{
margin: 10px;
padding: 20px
display: block;
border-radius: 50px;
font-size:1.5em;
cursor: pointer;
}
#toggleMute{
margin: 10px;
padding: 30px 0;
border-radius: 50px;
font-size:1.5em;
display: block;
position: fixed;
bottom: 0;
width:200px;
left: calc(50% - 100px);
}
.pressed {
background-color: red;
}
button {
margin:10px;
padding:10px;
}
</style>
</head>
<body>
<div id="clean">
<center>
Create a simple vdo.ninja/?push=XXX link in a different tab, enter the stream ID below to view it and view the IFRAME API stats. Stats will be printed to screen and console.<hr>
<input placeholder="Enter a VDON stream ID here" id="viewlink" type="text" />
<button id="startButton" onclick="loadIframes()" style="display:block;padding:10px;margin:10px;">START</button>
</center>
</div>
<div id="output"></div>
<script>
var iframe;
function sendSelfCommand(action, value = null) {
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage({ [action]: true}, '*');
}
}
function loadIframes() {
var streamID = document.getElementById("viewlink").value;
var path = "vdo.ninja";
var streamSrc = "https://" + path + "/?view=" + streamID;
document.getElementById("clean").style.display = "none";
var button = document.createElement("button");
button.id = "getDetailedState";
button.innerHTML = "Get Detailed State";
document.body.appendChild(button);
button.onclick = function () {
sendSelfCommand("getDetailedState", true);
};
var button2 = document.createElement("button");
button2.id = "getStats";
button2.innerHTML = "Get Full Connection Stats";
document.body.appendChild(button2);
button2.onclick = function () {
sendSelfCommand("getStats", true);
};
iframe = document.createElement("iframe");
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
iframe.src = streamSrc;
iframe.style.width = "100%";
iframe.style.height = "400px"; // Adjust the size as needed
document.body.appendChild(iframe);
window.addEventListener("message", function (e) {
if (e.source === iframe.contentWindow) {
console.log(e.data);
if (e.data.action && (e.data.action == "view-stats-updated")){return;} // annoying stat.
document.getElementById("output").innerText += JSON.stringify(e.data);
document.getElementById("output").innerHTML += "<br />";
}
}, false);
}
</script>
</body>
</html>

View File

@@ -1,12 +1,12 @@
<html>
<head>
<title>OBS.Ninja IFRAME Outgoing Stats Example</title>
<title>VDO.Ninja IFRAME Outgoing Stats Example</title>
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<link rel="icon" type="image/png" sizes="32x32" href="./images/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="./images/favicon-16x16.png" />
<link rel="icon" href="./images/favicon.ico" />
<link itemprop="thumbnailUrl" href="./images/obsNinja_logo_full.png" />
<link rel="icon" type="image/png" sizes="32x32" href="../media//favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../media/favicon-16x16.png" />
<link rel="icon" href=".../media/favicon.ico" />
<link itemprop="thumbnailUrl" href="../media/vdoNinja_logo_full.png" />
<link rel="stylesheet" type="text/css" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<style>
body {
@@ -43,7 +43,7 @@
<div class="container-fluid">
<div class="row controls" style="margin-bottom:15px;border-bottom:1px solid black;">
<div class="col-8">
<input type="text" class="form-control" style="width:95%;margin:10px auto;" placeholder="Enter an OBS.Ninja View URL here" value="" id="viewlink" />
<input type="text" class="form-control" style="width:95%;margin:10px auto;" placeholder="Enter an VDO.Ninja View URL here" value="" id="viewlink" />
</div>
<div class="col-4">
<div class="row">
@@ -61,8 +61,8 @@
<div class="col-5" id="sourcecontrols">
<div class="row text-light" style="margin-top:15px;">
<div class="col">
<p>This example will show all connections to the stream generated from this page using statistics gathered using the <a href="https://github.com/steveseguin/obsninja/blob/master/IFRAME.md">iFrame API</a>.</p>
<p>Click start to generate a stream using the OBS.Ninja URL shown. If you use the example URL shown, you can <a id="aView" href="" target="_blank">click here</a> to connect to this stream as a viewer in a new window/tab, this will then show in the table below. Expired connections will be removed after a short delay.</p>
<p>This example will show all connections to the stream generated from this page using statistics gathered using the <a href="https://github.com/steveseguin/vdoninja/blob/master/IFRAME.md">iFrame API</a>.</p>
<p>Click start to generate a stream using the VDO.Ninja URL shown. If you use the example URL shown, you can <a id="aView" href="" target="_blank">click here</a> to connect to this stream as a viewer in a new window/tab, this will then show in the table below. Expired connections will be removed after a short delay.</p>
</div>
</div>
<div class="row" style="margin-top:5px;">
@@ -127,38 +127,38 @@
if ("stats" in e.data) {
var now = new Date(); //Used for "Added" column and to remove stale viewers
for (var viewer in e.data.stats.outbound_stats) {
for (var viewer in e.data.stats.outbound) {
//Check to see if a row exists for this viewier, if not then its a new viewer and we should create a row
if ($("#obsn_viewer_" + viewer).length == 0) {
if ($("#vdon_viewer_" + viewer).length == 0) {
var h = now.getHours();
var m = now.getMinutes();
var s = now.getSeconds();
$('#viewers tbody').append('<tr id="obsn_viewer_' + viewer + '"><th class="obsn_viewer_label" scope="row"></th><td class="obsn_viewer_added">' + ("0" + h).slice(-2) + ':' + ("0" + m).slice(-2) + ':' + ("0" + s).slice(-2) + '</td><td class="obsn_viewer_qlr"></td><td class="obsn_viewer_resolution"></td><td class="obsn_viewer_platform"></td><td class="obsn_viewer_encoder"></td><td class="obsn_viewer_useragent"></td></tr>');
$('#viewers tbody').append('<tr id="vdon_viewer_' + viewer + '"><th class="vdon_viewer_label" scope="row"></th><td class="vdon_viewer_added">' + ("0" + h).slice(-2) + ':' + ("0" + m).slice(-2) + ':' + ("0" + s).slice(-2) + '</td><td class="vdon_viewer_qlr"></td><td class="vdon_viewer_resolution"></td><td class="vdon_viewer_platform"></td><td class="vdon_viewer_encoder"></td><td class="vdon_viewer_useragent"></td></tr>');
}
//Insert/update stats
//Initially objects can be available but without any attributes, check they exist and ignore till the basics are available
if (e.data.stats.outbound_stats[viewer] == undefined) continue;
if (e.data.stats.outbound_stats[viewer].info == undefined) continue;
if (e.data.stats.outbound[viewer] == undefined) continue;
if (e.data.stats.outbound[viewer].info == undefined) continue;
//Checking these exist as not all attributes are available straight away when stats are created
if (e.data.stats.outbound_stats[viewer].info.label != undefined) {
$("#obsn_viewer_" + viewer).find('.obsn_viewer_label').text(e.data.stats.outbound_stats[viewer].info.label);
if (e.data.stats.outbound[viewer].info.label != undefined) {
$("#vdon_viewer_" + viewer).find('.vdon_viewer_label').text(e.data.stats.outbound[viewer].info.label);
}
if (e.data.stats.outbound_stats[viewer].quality_Limitation_Reason != undefined) {
$("#obsn_viewer_" + viewer).find('.obsn_viewer_qlr').text(e.data.stats.outbound_stats[viewer].quality_Limitation_Reason);
if (e.data.stats.outbound[viewer].quality_Limitation_Reason != undefined) {
$("#vdon_viewer_" + viewer).find('.vdon_viewer_qlr').text(e.data.stats.outbound[viewer].quality_Limitation_Reason);
}
if (e.data.stats.outbound_stats[viewer].resolution != undefined) {
$("#obsn_viewer_" + viewer).find('.obsn_viewer_resolution').text(e.data.stats.outbound_stats[viewer].resolution);
if (e.data.stats.outbound[viewer].resolution != undefined) {
$("#vdon_viewer_" + viewer).find('.vdon_viewer_resolution').text(e.data.stats.outbound[viewer].resolution);
}
if (e.data.stats.outbound_stats[viewer].info.platform != undefined) {
$("#obsn_viewer_" + viewer).find('.obsn_viewer_platform').text(e.data.stats.outbound_stats[viewer].info.platform);
if (e.data.stats.outbound[viewer].info.platform != undefined) {
$("#vdon_viewer_" + viewer).find('.vdon_viewer_platform').text(e.data.stats.outbound[viewer].info.platform);
}
if (e.data.stats.outbound_stats[viewer].encoder != undefined) {
$("#obsn_viewer_" + viewer).find('.obsn_viewer_encoder').text(e.data.stats.outbound_stats[viewer].encoder);
if (e.data.stats.outbound[viewer].encoder != undefined) {
$("#vdon_viewer_" + viewer).find('.vdon_viewer_encoder').text(e.data.stats.outbound[viewer].encoder);
}
if (e.data.stats.outbound_stats[viewer].info.useragent != undefined) {
$("#obsn_viewer_" + viewer).find('.obsn_viewer_useragent').text(e.data.stats.outbound_stats[viewer].info.useragent);
if (e.data.stats.outbound[viewer].info.useragent != undefined) {
$("#vdon_viewer_" + viewer).find('.vdon_viewer_useragent').text(e.data.stats.outbound[viewer].info.useragent);
}
$("#obsn_viewer_" + viewer).data('last', now.getTime()); //Used below to remove old viewers
$("#vdon_viewer_" + viewer).data('last', now.getTime()); //Used below to remove old viewers
}
//Mark and then remove viewers who have not been seen for a while
$('#viewers tbody tr').each(function(el) {
@@ -243,7 +243,7 @@
//Add in random ID and password strings to URL's, the below is purely for the purposes of this example
var pushid = makeid();
var password = makeid();
var baseUrl = "https://obs.ninja/";
var baseUrl = "https://vdo.ninja/";
$('#aView').attr('href', baseUrl + '?view=' + pushid + '&password=' + password + '&label=Test_Link');
$('#viewlink').val(baseUrl + '?push=' + pushid + '&password=' + password + '&autostart&turn=false&fps=25&maxbitrate=1000&cleanoutput&audiobitrate=32&aec=0&denoise=0&webcam');
});

View File

@@ -0,0 +1,434 @@
## Enhanced IFRAME API Documentation - HTTP/WSS API Integration
### Overview
The VDO.Ninja IFRAME API provides access to all HTTP/WSS API commands through the `action` parameter. This means you can use any command from the [HTTP/WSS API](https://github.com/steveseguin/Companion-Ninja) directly through the iframe's postMessage interface.
### Using HTTP/WSS API Commands via IFRAME
All commands available in the HTTP/WSS API can be accessed through the IFRAME API using this format:
```javascript
iframe.contentWindow.postMessage({
action: "commandName",
value: "value",
value2: "optional",
target: "optional", // for director commands
cib: "callback-id" // optional callback identifier
}, "*");
```
### Director Permissions
**Important:** To use director commands for remote control, you must have director permissions:
1. Use `&director=roomname` instead of `&room=roomname` in your iframe URL
2. Or combine with `&codirector=password` to enable multiple directors
3. Without proper permissions, director commands will fail silently
Example iframe URL with director permissions:
```
https://vdo.ninja/?director=myroom&cleanoutput&api=myapikey
```
### Complete Command Reference
#### Self Commands (No Target Required)
These commands affect the local VDO.Ninja instance:
```javascript
// Microphone control
iframe.contentWindow.postMessage({ action: "mic", value: "toggle" }, "*");
// Camera control
iframe.contentWindow.postMessage({ action: "camera", value: false }, "*");
// Speaker control
iframe.contentWindow.postMessage({ action: "speaker", value: true }, "*");
// Volume control (0-200)
iframe.contentWindow.postMessage({ action: "volume", value: 85 }, "*");
// Recording
iframe.contentWindow.postMessage({ action: "record", value: true }, "*");
// Bitrate control
iframe.contentWindow.postMessage({ action: "bitrate", value: 2500 }, "*");
// Layout control
iframe.contentWindow.postMessage({ action: "layout", value: 2 }, "*");
// Custom layout object
iframe.contentWindow.postMessage({
action: "layout",
value: [
{x: 0, y: 0, w: 50, h: 100, slot: 0},
{x: 50, y: 0, w: 50, h: 100, slot: 1}
]
}, "*");
// Group management
iframe.contentWindow.postMessage({ action: "joinGroup", value: "1" }, "*");
iframe.contentWindow.postMessage({ action: "leaveGroup", value: "2" }, "*");
// Get information
iframe.contentWindow.postMessage({ action: "getDetails", cib: "details-123" }, "*");
iframe.contentWindow.postMessage({ action: "getGuestList", cib: "guests-456" }, "*");
// Camera PTZ controls
iframe.contentWindow.postMessage({ action: "zoom", value: 0.1 }, "*"); // Relative
iframe.contentWindow.postMessage({ action: "zoom", value: 1.5, value2: "abs" }, "*"); // Absolute
iframe.contentWindow.postMessage({ action: "pan", value: -0.5 }, "*");
iframe.contentWindow.postMessage({ action: "tilt", value: 0.1 }, "*");
iframe.contentWindow.postMessage({ action: "focus", value: 0.8, value2: "abs" }, "*");
// Other controls
iframe.contentWindow.postMessage({ action: "reload" }, "*");
iframe.contentWindow.postMessage({ action: "hangup" }, "*");
iframe.contentWindow.postMessage({ action: "togglehand" }, "*");
iframe.contentWindow.postMessage({ action: "togglescreenshare" }, "*");
iframe.contentWindow.postMessage({ action: "forceKeyframe" }, "*");
iframe.contentWindow.postMessage({ action: "sendChat", value: "Hello everyone!" }, "*");
```
#### Director Commands (Target Required)
These commands require director permissions and target specific guests:
```javascript
// Target can be:
// - Slot number: "1", "2", "3", etc.
// - Stream ID: "abc123xyz"
// - "*" for all guests (where applicable)
// Guest microphone control
iframe.contentWindow.postMessage({
action: "mic",
target: "1",
value: "toggle"
}, "*");
// Guest camera control
iframe.contentWindow.postMessage({
action: "camera",
target: "streamID123",
value: false
}, "*");
// Add guest to scene
iframe.contentWindow.postMessage({
action: "addScene",
target: "2",
value: 1 // Scene number
}, "*");
// Transfer guest to another room
iframe.contentWindow.postMessage({
action: "forward",
target: "1",
value: "newroom"
}, "*");
// Solo chat with guest
iframe.contentWindow.postMessage({
action: "soloChat",
target: "3"
}, "*");
// Two-way solo chat
iframe.contentWindow.postMessage({
action: "soloChatBidirectional",
target: "2"
}, "*");
// Send private message to guest
iframe.contentWindow.postMessage({
action: "sendChat",
target: "1",
value: "Private message"
}, "*");
// Overlay message on guest's screen
iframe.contentWindow.postMessage({
action: "sendDirectorChat",
target: "2",
value: "You're live in 10 seconds!"
}, "*");
// Guest volume control
iframe.contentWindow.postMessage({
action: "volume",
target: "1",
value: 120 // 0-200
}, "*");
// Disconnect specific guest
iframe.contentWindow.postMessage({
action: "hangup",
target: "3"
}, "*");
// Guest camera PTZ control
iframe.contentWindow.postMessage({
action: "zoom",
target: "1",
value: 0.1
}, "*");
// Timer controls for guest
iframe.contentWindow.postMessage({
action: "startRoomTimer",
target: "1",
value: 600 // 10 minutes in seconds
}, "*");
// Change guest position in mixer
iframe.contentWindow.postMessage({
action: "mixorder",
target: "2",
value: -1 // Move up
}, "*");
```
### Using targetGuest Function (Legacy)
The `targetGuest` function provides another way to control guests:
```javascript
iframe.contentWindow.postMessage({
function: "targetGuest",
target: "1", // Guest slot or stream ID
action: "mic", // Action to perform
value: "toggle" // Value (optional)
}, "*");
```
### Using Commands Function
Access any command from the Commands object:
```javascript
iframe.contentWindow.postMessage({
function: "commands",
action: "zoom",
value: 0.5,
value2: "abs"
}, "*");
```
### Advanced DOM Manipulation
Target specific video elements by stream ID:
```javascript
// Add video to grid
iframe.contentWindow.postMessage({
target: "streamID123",
add: true
}, "*");
// Remove video from grid
iframe.contentWindow.postMessage({
target: "streamID123",
remove: true
}, "*");
// Replace all videos with target
iframe.contentWindow.postMessage({
target: "streamID123",
replace: true
}, "*");
// Apply settings to video element
iframe.contentWindow.postMessage({
target: "streamID123",
settings: {
style: "transform: scale(1.5);",
muted: true,
volume: 0.5
}
}, "*");
```
### Special Functions
```javascript
// Preview local webcam
iframe.contentWindow.postMessage({
function: "previewWebcam"
}, "*");
// Publish screen share
iframe.contentWindow.postMessage({
function: "publishScreen"
}, "*");
// Change HTML content
iframe.contentWindow.postMessage({
function: "changeHTML",
target: "elementId",
value: "<p>New content</p>"
}, "*");
// Route WebSocket message
iframe.contentWindow.postMessage({
function: "routeMessage",
value: { /* message data */ }
}, "*");
// Execute code (use with extreme caution)
iframe.contentWindow.postMessage({
function: "eval",
value: "console.log('Hello from eval');"
}, "*");
```
### Handling Responses
Listen for responses with callback IDs:
```javascript
window.addEventListener("message", function(e) {
if (e.source !== iframe.contentWindow) return;
if (e.data.cib === "my-callback-123") {
console.log("Received response:", e.data);
// Handle different response types
if (e.data.guestList) {
console.log("Guest list:", e.data.guestList);
} else if (e.data.detailedState) {
console.log("State info:", e.data.detailedState);
} else if (e.data.callback) {
console.log("Command result:", e.data.callback.result);
}
}
});
```
### Complete Example: Director Control Panel
```html
<!DOCTYPE html>
<html>
<head>
<title>VDO.Ninja Director Control Panel</title>
</head>
<body>
<h1>Director Control Panel</h1>
<div id="container"></div>
<div id="controls">
<h2>Guest Controls</h2>
<select id="guest-select">
<option value="1">Guest 1</option>
<option value="2">Guest 2</option>
<option value="3">Guest 3</option>
</select>
<button onclick="controlGuest('mic', 'toggle')">Toggle Mic</button>
<button onclick="controlGuest('camera', 'toggle')">Toggle Camera</button>
<button onclick="controlGuest('addScene', 1)">Add to Scene 1</button>
<button onclick="controlGuest('forward', 'lobby')">Send to Lobby</button>
<button onclick="controlGuest('zoom', 0.1)">Zoom In</button>
<button onclick="controlGuest('zoom', -0.1)">Zoom Out</button>
</div>
<div id="log"></div>
<script>
// Create iframe with director permissions
const iframe = document.createElement("iframe");
iframe.allow = "camera;microphone;fullscreen;display-capture;autoplay;";
iframe.src = "https://vdo.ninja/?director=myroom&cleanoutput&api=mykey";
iframe.style.width = "800px";
iframe.style.height = "600px";
document.getElementById("container").appendChild(iframe);
// Control function
function controlGuest(action, value) {
const target = document.getElementById("guest-select").value;
const message = {
action: action,
target: target
};
if (value !== undefined) {
message.value = value;
}
// Generate callback ID
const callbackId = `cb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
message.cib = callbackId;
iframe.contentWindow.postMessage(message, "*");
log(`Sent: ${JSON.stringify(message)}`);
}
// Listen for responses
window.addEventListener("message", function(e) {
if (e.source !== iframe.contentWindow) return;
log(`Received: ${JSON.stringify(e.data)}`);
// Handle specific events
if (e.data.action === "guest-connected") {
log(`Guest connected: ${e.data.streamID}`);
} else if (e.data.guestList) {
updateGuestList(e.data.guestList);
}
});
// Logging
function log(message) {
const logDiv = document.getElementById("log");
const entry = document.createElement("div");
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
}
// Update guest list
function updateGuestList(guests) {
const select = document.getElementById("guest-select");
select.innerHTML = "";
guests.forEach((guest, index) => {
const option = document.createElement("option");
option.value = guest.id || (index + 1);
option.textContent = guest.label || `Guest ${index + 1}`;
select.appendChild(option);
});
}
// Get initial guest list
setTimeout(() => {
iframe.contentWindow.postMessage({
action: "getGuestList",
cib: "initial-guests"
}, "*");
}, 2000);
</script>
</body>
</html>
```
### Important Notes
1. **Director Permissions**: Always use `&director=roomname` or `&codirector=password` for director commands
2. **Target Format**: Use slot numbers (1, 2, 3) or stream IDs for targeting
3. **Callback IDs**: Use unique `cib` values to track responses
4. **Error Handling**: Commands may fail silently without proper permissions
5. **Timing**: Wait for iframe to load before sending commands
### Troubleshooting
- **Commands not working**: Check director permissions in iframe URL
- **No response**: Verify callback ID handling and message source
- **Guest not found**: Confirm target value matches slot or stream ID
- **Permission errors**: Ensure using `&director=` not `&room=`
This integration allows you to build powerful control interfaces using the full capabilities of the VDO.Ninja API through simple iframe messaging.

1111
examples/iframeapi.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +1,541 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="../main.css?ver=40" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VDO.Ninja Examples Catalog</title>
<link rel="stylesheet" href="https://vdo.ninja/main.css" />
<style>
:root {
--bg: #0f131d;
--card-bg: #1c2333;
--card-border: rgba(255, 255, 255, 0.08);
--text-muted: #c2cad8;
--accent: #8ecae6;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: #f5f6f8;
font-family: Inter, "Segoe UI", system-ui, -apple-system, sans-serif;
position: relative !important;
}
a {
color: #edf6ff;
text-decoration: none;
}
a:hover,
a:focus-visible {
text-decoration: underline;
}
#header {
padding: 16px 24px;
background: rgba(0, 0, 0, 0.25);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
#header a {
color: #f5f6f8;
font-size: 1.6rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
}
.container {
max-width: 900px;
width: fit-content;
max-width: 1120px;
margin: 0 auto;
padding: 40px 24px 64px;
}
h1 {
margin-top: 3em;
.page-header {
margin-bottom: 32px;
}
h2 {
font-size: 1.2em;
padding: 10px;
background-color: #457b9d;
color: white;
border-bottom: 2px solid #3b6a87;
text-align: center;
.page-header h1 {
margin: 0 0 0.6em;
font-size: clamp(2rem, 2.8vw, 2.6rem);
letter-spacing: -0.01em;
}
h2 a {
color: white !important;
.page-header p {
margin: 0 0 0.8em;
color: var(--text-muted);
max-width: 70ch;
line-height: 1.6;
}
#examples {
margin-top: 3em;
#example-groups {
display: flex;
flex-direction: column;
gap: 48px;
}
.group h2 {
margin: 0;
font-size: clamp(1.4rem, 2.2vw, 1.8rem);
}
.group-description {
margin: 8px 0 24px;
color: var(--text-muted);
max-width: 70ch;
line-height: 1.6;
}
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 1em;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 18px;
}
div#examples>div {
background: #dddddd;
color: black;
.example-card {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 190px;
padding: 18px;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 14px;
box-shadow: 0 18px 40px -32px rgba(9, 11, 19, 0.9);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.description {
padding: 1em;
display: block;
.example-card:hover {
transform: translateY(-4px);
box-shadow: 0 22px 42px -28px rgba(11, 15, 25, 0.85);
}
.youtube {
display: block;
text-align: right;
.example-card h3 {
margin: 0;
font-size: 1.15rem;
letter-spacing: -0.01em;
}
.media {
background: hsl(203deg 26% 73%);
display: block;
width: 100%;
padding: 0.2em;
.example-card p {
margin: 0;
color: var(--text-muted);
line-height: 1.5;
}
.tag-list {
margin: auto 0 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-list li {
padding: 4px 10px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
border-radius: 999px;
background: rgba(142, 202, 230, 0.14);
color: var(--accent);
}
@media (max-width: 720px) {
.container {
padding: 28px 18px 48px;
}
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
}
</style>
</head>
<body style='color:white'>
<body>
<div id="header">
<a id="logoname" href="./" style="text-decoration: none; color: white; margin: 2px">
<a id="logoname" href="../">
<span data-translate="logo-header">
<font id="qos">V</font>DO.Ninja
</span>
</a>
</div>
<div class="container">
<div id="info">
<h1>VDO.Ninja tech demonstrations</h1>
<div id="examples">
<div>
<h2><a href='p2p.html'>p2p</a></h2>
<div class="description">How to use vdo.ninja as a data transport tunneling service</div>
</div>
<div>
<h2><a href='twitch.html'>twitch</a></h2>
<div class="description">How to have a twitch live chat side-by-side with VDO.NInja on the same
screen</div>
</div>
<div>
<h2><a href='dual.html'>dual</a></h2>
<div class="description">how to have two VDO.Ninja windows (or any windows really) open on the same
page;
Picture-in-Picture style</div>
</div>
<div>
<h2><a href='sensors.html'>sensors</a></h2>
<div class="media">
<a href='https://www.youtube.com/watch?v=SqbufszHKi4' class="youtube">
<img src="youtube.svg" />
</a>
</div>
<div class="description">how to transmit sensor and video data from a phone to a computer, drawing
it to canvas.
</div>
</div>
<div>
<h2><a href='../midi.html'>midi</a></h2>
<div class="media">
<a href='https://www.youtube.com/watch?v=rnZ8HM9FL4I' class="youtube">
<img src="youtube.svg" />
</a>
</div>
<div class="description">Demonstrates the MIDI API for VDO.Ninja
<main class="container">
<section class="page-header">
<h1>Examples &amp; Experiments</h1>
<p>These demos explore different ways to embed, automate, and customize VDO.Ninja. Every page is optional,
URL-driven, and kept separate from the core experience, so feel free to remix them for your own
workflows.</p>
<p>Most pages expect you to launch them with live push/view links or room IDs. Look at the comments or
top-of-page instructions for required parameters, or tweak the URL to try different behaviours.</p>
</section>
<div id="example-groups"></div>
</main>
<script>
const exampleGroups = [
{
name: "Remote Control & Automation",
description: "Control rooms, scenes, or devices remotely using the IFRAME API, data channels, and postMessage hooks.",
items: [
{
href: "addtoscene.html",
title: "Add to Scene Controller",
summary: "Use the IFRAME API to add or remove pre-defined stream IDs from a scene and manage their mic state."
},
{
href: "bigmutebutton.html",
title: "Big Mute Button Remote",
summary: "Mobile-friendly remote that lets talent toggle their microphone with oversized controls."
},
{
href: "control.html",
title: "Legacy Layout Console",
summary: "Send quick layout macros to the director scene to demo the classic control surface."
},
{
href: "custom_video_switcher.html",
title: "Custom Video Switcher",
summary: "Swap between preset stream IDs in a manual scene using targeted postMessage commands."
},
{
href: "esports.html",
title: "Esports POV Toggler",
summary: "Showcase multiple POV feeds with buttons to reveal each player or clear the scene."
},
{
href: "muteguestiframe.html",
title: "Mute Guest via IFRAME",
summary: "Remotely toggle speaker and mic states for a specific stream ID inside an embedded scene."
},
{
href: "obsremote.html",
title: "OBS Remote Mini",
summary: "Lightweight interface that tunnels OBS websocket control through VDO.Ninja."
},
{
href: "obs_remote/index.html",
title: "OBS Remote Dashboard",
summary: "Full-featured OBS controller with previews and macros, proxied through VDO.Ninja."
},
{
href: "powerpoint.html",
title: "PowerPoint Remote",
summary: "Flip through slides remotely while embedding a live view of the deck."
},
{
href: "ptz.html",
title: "PTZ Remote",
summary: "Pan, tilt, and zoom a remote camera by sending PTZ commands over the data channel."
},
{
href: "remoteapi.html",
title: "Remote API Playground",
summary: "Retro-themed sandbox for experimenting with remote control actions and macros."
},
{
href: "slidingzoom.html",
title: "Sliding Zoom Controller",
summary: "Touch-friendly slider that adjusts zoom levels via VDO.Ninja PTZ hooks."
},
{
href: "switchmics.html",
title: "Switch Mics",
summary: "Toggle mute on two remote guests with single-click controls for quick audio checks."
},
{
href: "teleprompt.html",
title: "Teleprompt Controller",
summary: "Producer-facing interface to push script text and settings to the teleprompter view."
}
]
},
{
name: "Production Overlays & Layouts",
description: "Browser sources and layouts you can drop into OBS, a switcher, or a director tab.",
items: [
{
href: "chat.html",
title: "Chat Shout Overlay",
summary: "Monospaced stacked chat overlay tuned for big on-screen shoutouts."
},
{
href: "chatoverlay.html",
title: "Clean Chat Overlay",
summary: "Modern chat overlay for OBS with avatars, styling toggles, and sanitised content."
},
{
href: "custom_labels.html",
title: "Custom Labels Overlay",
summary: "Display lower-third labels that update automatically as guests join with custom names."
},
{
href: "custom_overlay.html",
title: "Dynamic Overlay Frame",
summary: "Trigger branded overlays and nameplates from connection metadata."
},
{
href: "draggable.html",
title: "Draggable Multi-View",
summary: "Arrange several viewer windows manually by dragging and resizing thumbnails."
},
{
href: "dual.html",
title: "Dual View Layout",
summary: "Display two guests side-by-side using the dual director layout logic."
},
{
href: "gamecontroller.html",
title: "Controller Visualizer",
summary: "Render HID and gamepad events as an overlay-friendly controller graphic."
},
{
href: "grid.html",
title: "Grid Builder",
summary: "Drop arbitrary URLs into a responsive iframe grid for multi-angle monitoring."
},
{
href: "labelonly.html",
title: "Label Only Overlay",
summary: "Show only the connected guest's label as a minimal lower-third element."
},
{
href: "mixer.html",
title: "Mixer Sandbox",
summary: "Try the manual scene mixer by dragging streams into layout slots."
},
{
href: "multi.html",
title: "Multi-Room Monitor",
summary: "Open multiple director rooms in one tab using the ?rooms= list parameter."
},
{
href: "overlay.html",
title: "Overlay Helper",
summary: "Combine a VDO.Ninja feed with an external overlay page inside one source."
},
{
href: "rotated.html",
title: "Rotated Scene Output",
summary: "Rotate the scene output for portrait monitors or tall confidence displays."
},
{
href: "sensoroverlay.html",
title: "Sensor Data Overlay",
summary: "Render live speed and telemetry data over an incoming video feed."
},
{
href: "status.html",
title: "Status Ticker",
summary: "Ticker-style overlay for status updates pulled from chat events."
},
{
href: "teleprompter.html",
title: "Teleprompter Display",
summary: "Talent-facing teleprompter view that mirrors incoming script text."
},
{
href: "waitingroom.html",
title: "Waiting Room Overlay",
summary: "Show a standby message until the remote feed connects and starts playing."
}
]
},
{
name: "Social & Platform Integrations",
description: "Pair VDO.Ninja streams with third-party chat or engagement widgets.",
items: [
{
href: "kick.html",
title: "Kick + Video",
summary: "Combine a Kick chat embed with a VDO.Ninja source in one window."
},
{
href: "socal.html",
title: "SocialStream Hub",
summary: "Switch between multiple social chat integrations alongside a VDO.Ninja feed."
},
{
href: "twitch.html",
title: "Twitch + Video",
summary: "Embed Twitch chat next to a VDO.Ninja feed for streamers."
},
{
href: "youtube.html",
title: "YouTube Chat + Video",
summary: "Co-host YouTube live chat with a VDO.Ninja guest feed."
}
]
},
{
name: "Data, Sensors & Messaging",
description: "Examples that push telemetry, sensor readings, or custom data through VDO.Ninja.",
items: [
{
href: "datachannel-pubsub.html",
title: "DataChannel Pub/Sub",
summary: "Publish structured messages and subscribe to updates over VDO.Ninja data channels."
},
{
href: "p2p.html",
title: "P2P Data Tunnel",
summary: "Use paired iframes to pass arbitrary data peer-to-peer via VDO.Ninja."
},
{
href: "sensors.html",
title: "Sensors Dashboard",
summary: "Capture mobile sensor readings and video, rendering telemetry on canvas."
},
{
href: "rip.html",
title: "RIP Canvas Relay",
summary: "Capture a remote view to a hidden canvas for further processing or mixing."
},
{
href: "wireless.html",
title: "Wireless Relay Lab",
summary: "Manual message relay between two peers with testing and compression controls."
}
]
},
{
name: "Developer & SDK Samples",
description: "Deeper dives into the SDK, automation helpers, and hardware integrations.",
items: [
{
href: "dynamic-viewer.html",
title: "Dynamic Viewer SDK",
summary: "UI-driven sample that adds and removes views dynamically with the SDK helpers."
},
{
href: "googleai.html",
title: "Gemini Vision Chat",
summary: "Integrate Google Gemini live video analysis with a VDO.Ninja feed."
},
{
href: "iframe.inbound-stats.html",
title: "IFRAME Inbound Stats",
summary: "Request inbound stats over the IFRAME API and log them for inspection."
},
{
href: "iframe.outbound-stats.html",
title: "IFRAME Outbound Stats",
summary: "Collect outbound stats from an embedded iframe for monitoring."
},
{
href: "midi.html",
title: "MIDI Controller",
summary: "Map MIDI inputs to VDO.Ninja events and test hotkey commands."
},
{
href: "sandbox.html",
title: "Developer API Sandbox",
summary: "All-in-one playground for experimenting with the VDO.Ninja developer API."
},
{
href: "simple-iframe-replacement.html",
title: "DataChannel Replacement",
summary: "Shows how to replace hidden iframes with the DataChannel SDK."
},
{
href: "turn-only-example.html",
title: "TURN Only Example",
summary: "Demonstrates forcing TURN-only routing and inspecting connection details."
},
{
href: "webhid.html",
title: "WebHID Demo",
summary: "Connect WebHID devices such as a StreamDeck and forward events through VDO.Ninja."
}
]
},
{
name: "Utilities & Helpers",
description: "Small helpers for link generation, diagnostics, or local capture workflows.",
items: [
{
href: "changepass.html",
title: "Password Hasher",
summary: "Prompt-driven tool that creates salted room hashes for invite links."
},
{
href: "noisegate.html",
title: "Noise Gate Converter",
summary: "Convert classic OBS noise gate settings into the modern \"My Gate\" format."
},
{
href: "simplelink.html",
title: "Simple Link Generator",
summary: "Generate publish, view, and scene links with common parameters."
},
{
href: "zoom.html",
title: "Zoom Capture Helper",
summary: "Local capture preview that goes fullscreen for easy window capture."
}
]
}
];
</div>
</div>
<div>
<h2><a href='draggable.html'>draggable</a></h2>
<div class="description">demonstrates how to drag multiple
windows around, if you wanted to create a custom
layout of elements. (experimental)</div>
</div>
<div>
<h2><a href='chat.html'>chat</a></h2>
<div class="description">Example of a chat-only interface for VDO.Ninja; maybe
dockable into OBS even.</div>
</div>
<div>
<h2><a href='iframe.outbound-stats.html'>iframe.outbound-stats</a></h2>
<div class="description">iframe.outbound-stats.html demostrates how to get stats from VDO.Ninja
using the
IFRAME API</div>
</div>
<div>
<h2><a href='changepass.html'>changepass</a></h2>
<div class="description">lets you create passwords and related HASH values for VDO.NInja
rooms</div>
</div>
<div>
<h2><a href='webhid.html'>webhid</a></h2>
<div class="description">webhid demonstrates how to interface with a USB device, like a streamdeck
(mouse/keyboard not supported)</div>
</div>
<div>
<h2><a href='zoom.html'>zoom</a></h2>
<div class="description">A tool for letting you publish into VDO.Ninja, but then
full-screen the window once setup, allowing for
window-capturing into zoom.</div>
</div>
<div>
<h2><a href='obs_remote/index.html'>obs_remote</a></h2>
<div class="media">
<a href='https://github.com/steveseguin/remote_ninja' class="youtube">
<img src="github.svg" />
</a>
</div>
const groupsRoot = document.getElementById("example-groups");
<div class="description">Also hosted on github elsewhere, but it's an example of how to remotely
control OBS using VDO.Ninja's tunneling abilities</div>
</div>
</div>
</div>
</body>
for (const group of exampleGroups) {
const section = document.createElement("section");
section.className = "group";
const heading = document.createElement("h2");
heading.textContent = group.name;
section.appendChild(heading);
if (group.description) {
const desc = document.createElement("p");
desc.className = "group-description";
desc.textContent = group.description;
section.appendChild(desc);
}
const grid = document.createElement("div");
grid.className = "card-grid";
for (const item of group.items) {
const card = document.createElement("article");
card.className = "example-card";
const title = document.createElement("h3");
const link = document.createElement("a");
link.href = item.href;
link.textContent = item.title;
title.appendChild(link);
card.appendChild(title);
const summary = document.createElement("p");
summary.textContent = item.summary;
card.appendChild(summary);
if (Array.isArray(item.tags) && item.tags.length) {
const tagList = document.createElement("ul");
tagList.className = "tag-list";
for (const tag of item.tags) {
const tagItem = document.createElement("li");
tagItem.textContent = tag;
tagList.appendChild(tagItem);
}
card.appendChild(tagList);
}
grid.appendChild(card);
}
section.appendChild(grid);
groupsRoot.appendChild(section);
}
</script>
</body>
</html>

363
examples/kick.html Normal file
View File

@@ -0,0 +1,363 @@
<html>
<head><title>Kick + Video</title>
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0,user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color:#000;
width:100vw;
height:100vh;
color:white;
overscroll-behavior: contain;
overflow: hidden;
display:block;
}
iframe {
width:100%;
height:100%;
border:0;
margin:0;
padding:0;
display:block;
}
input{
padding:10px 2px;
width:calc(100vw - 60px);
font-size: min(4vw, 20px);
margin:10px 0 30px 0;
z-index: 1000;
color:black;
display:block;
}
#clean{
max-width:100%;
width: 90vw;
max-width: 100%;
display: inline-block;
}
h1{
color: white;
font-family: verdana;
margin: 10px 3px 30px 3px;
color:white;
}
button {
font-size: min(10vw, 28px);
color:black;
display:inline-block;
}
#controlbar {
position: absolute;
top: 0;
left: 0;
background-color: #0000;
height: max(7vh, 50px);
width: 100%;
display: none;
justify-content: center;
}
#container2{
background-color:#000;
}
#container1{
transition: all ease 0.4ms;
}
#controlbar button{
vertical-align: middle;
text-align: center;
height: 100%;
background-color: black;
color: white;
box-shadow: inset 0 0 20px 7px #FFF7;
font-size: 1.0em;
border-radius: 19px;
max-width: 20%;
margin: 0;
padding: 0 2px;
}
.pressed {
background-color: #A00!important;
}
.loading {
width: 100%!important; height:100%!important; position: absolute; top: 0; right:unset;left:0;opacity:100%; animation: fadeIn 3s;
}
.fullwindow {
height: calc(100% - max(7vh, 50px)) !important; width:100%!important; position: absolute; top: max(7vh, 50px)!important; right:unset!important;left:0!important;
}
@keyframes fadeIn {
0% { opacity: 0; }
10% { opacity: 0; }
50% { opacity: 0.2; }
100% { opacity: 1; }
}
@media screen and (orientation:portrait) {
#container2{
width:100%;height:100%;display:none;
}
#container1{
width: 50vw;height: 50vh; display:none; top: 0; right: 0%; position: absolute;
}
}
@media screen and (orientation:landscape) {
#container2{
width:60vw;height:100%;display:none;
z-index:5;
}
#container1{
width: 50vw; height:100%; max-height: calc(100vh - 100px); display:none; position: fixed; top: 0; right: -10vw;
}
}
.hide{
width: 1px!important;
height: 1px!important;
opacity: 0.5;
}
</style>
</head>
<body>
<div id="container2" ></div>
<div id="container1" class="loading"></div>
<div id="controlbar" >
<button id="hidepreview">Hide Preview</button>
<button id="mutemic">Mute Mic</button>
<button id="mutevideo">Mute Video</button>
<button id="togglesettings">Settings</button>
<button id="hangup">Hangup</button>
</div>
<div id="clean">
<h1>Use VDO.Ninja and Kick chat at the same time</h1>
VDO.Ninja Stream ID or URL:
<input placeholder="Enter a VDON stream ID or VDON URL" id="viewlink" type="text" />
Kick Username or URL:
<input placeholder="Enter the Kick channel name" id="kick" type="text" />
<button onclick="loadIframes()" style="background-color: #d1fed1;; padding:10px;margin:10px;">START</button>
<button onclick="clearInput()" style="background-color: #f4cccc;margin:10px 0px 10px 10vh;padding:10px;">CLEAR</button>
<br /><br /><br />
<p>
This app lets you publish video/audio via VDO.Ninja at the same time as viewing your Kick chat.<br /><br />If you have feature requests or suggestions, please report them at https://discord.vdo.ninja in the #feature-request channel.
</p>
<h2>If on mobile, enter "desktop site" mode within your browser's settings</h2>
</div>
<script>
window.addEventListener("orientationchange", function() {
// Announce the new orientation number
// alert(window.orientation);
}, false);
function removeStorage(cname){
localStorage.removeItem(cname);
}
function setStorage(cname, cvalue, hours=9999){ // not actually a cookie
var now = new Date();
var item = {
value: cvalue,
expiry: now.getTime() + (hours * 60 * 60 * 1000),
};
try{
localStorage.setItem(cname, JSON.stringify(item));
}catch(e){errorlog(e);}
}
function getStorage(cname) {
try {
var itemStr = localStorage.getItem(cname);
} catch(e){
errorlog(e);
return;
}
if (!itemStr) {
return "";
}
var item = JSON.parse(itemStr);
var now = new Date();
if (now.getTime() > item.expiry) {
localStorage.removeItem(cname);
return "";
}
return item.value;
}
if (getStorage("kickChatLink")){
document.getElementById("kick").value = getStorage("kickChatLink");
}
if (getStorage("vdoNinjaKickURL")){
document.getElementById("viewlink").value = getStorage("vdoNinjaKickURL");
}
function clearInput(){
var confirmit = confirm("Are you sure you want to clear the input fields and local storage?");
if (confirmit){
removeStorage("kickChatLink");
removeStorage("vdoNinjaKickURL");
document.getElementById("viewlink").value = "";
document.getElementById("kick").value = "";
}
}
var iframe = null;
function sendSelfCommand(action, value=null){
iframe.contentWindow.postMessage({"target":null, "action":action, "value":value}, '*');
}
var injectCSS = `
#controlButtons{
display:none!important;
}
`;
injectCSS = encodeURIComponent(btoa(injectCSS));
function loadIframes(url=false){
var roomname = document.getElementById("viewlink").value;
var kick = document.getElementById("kick").value;
document.getElementById("clean").parentNode.removeChild(document.getElementById("clean"));
document.getElementById("container1").style.display="inline-block";
document.getElementById("container2").style.display="inline-block";
var path = window.location.host+window.location.pathname.split("/").slice(0,-1).join("/");
path = path.replace("/examples","");
if (roomname.startsWith("https://")){
var room1 = roomname;
} else {
var room1 = "https://"+path+"/?push="+roomname+"&webcam&autostart&vd=front&ad=1&transparent&noheader&fullscreen&cleanish&b64css="+injectCSS;
}
var room2 = kick.startsWith("https://") ? kick : `https://kick.com/${kick}/chatroom`;
iframe = document.createElement("iframe");
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
iframe.src = room1;
document.getElementById("container1").appendChild(iframe);
//////////// LISTEN FOR EVENTS
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
/// If you have a routing system setup, you could have just one global listener for all iframes instead.
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
console.warn(e.data);
if ("action" in e.data){
if (e.data.action === "seeding-started"){
document.getElementById("controlbar").style.display="inline-flex";
document.getElementById("container1").classList.remove("loading");
}
if (e.data.action === "settings-menu-state"){
if (e.data.value==true){
togglesettings.dataset.value = "true";
togglesettings.classList.add("pressed");
document.getElementById("container1").classList.add("fullwindow");
} else {
togglesettings.dataset.value = "false";
togglesettings.classList.remove("pressed");
document.getElementById("container1").classList.remove("fullwindow");
}
}
}
});
setStorage("kickChatLink", room2);
setStorage("vdoNinjaKickURL", room1);
setTimeout(function(){
var iframe2 = document.createElement("iframe");
iframe2.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
iframe2.src = room2;
document.getElementById("container2").appendChild(iframe2);
},1000);
}
var hangup = document.getElementById("hangup");
hangup.onclick = function(){
iframe.contentWindow.postMessage({"hangup":true}, '*');
}
var togglesettings = document.getElementById("togglesettings");
togglesettings.onclick = function(){
iframe.contentWindow.postMessage({"toggleSettings":"toggle"}, '*');
}
var mutemic = document.getElementById("mutemic");
mutemic.onclick = function(){
if (this.dataset.value!=="false"){
this.dataset.value = "false";
this.classList.add("pressed");
this.innerText = "Un-Mute Mic";
sendSelfCommand("mic",false);
} else {
this.classList.remove("pressed");
this.innerText = "Mute Mic";
this.dataset.value = "true";
sendSelfCommand("mic",true);
}
}
var mutevideo = document.getElementById("mutevideo");
mutevideo.onclick = function(){
if (this.dataset.value!=="false"){
this.dataset.value = "false";
this.classList.add("pressed");
this.innerText = "Un-Mute camera";
sendSelfCommand("camera",false);
} else {
this.classList.remove("pressed");
this.innerText = "Mute Camera";
this.dataset.value = "true";
sendSelfCommand("camera",true);
}
}
var hidepreview = document.getElementById("hidepreview");
hidepreview.onclick = function(){
if (this.dataset.value!=="false"){
this.dataset.value = "false";
this.classList.add("pressed");
this.innerText = "Show Preview";
document.getElementById("container1").classList.add("hide");
document.getElementById("container2").classList.add("fullwindow");
} else {
this.classList.remove("pressed");
this.innerText = "Hide Preview";
this.dataset.value = "true";
document.getElementById("container1").classList.remove("hide");
document.getElementById("container2").classList.remove("fullwindow");
}
}
</script>
</body>
</html>

165
examples/labelonly.html Normal file
View File

@@ -0,0 +1,165 @@
<!--
VDO.Ninja Connected Label Overlay
Description: Displays the label of the first connected peer in an overlay for OBS Studio
Parameters:
&room=xx - Comma-separated room IDs to connect to
&password=pp - Optional password for the rooms
&view=xxxx - Optional view parameter
Usage:
- Add as a browser source in OBS Studio
- Can be hosted locally: file:///C:/Users/steve/Code/vdoninja/examples/labelonly.html?view=steve123
- Or online: https://vdo.ninja/examples/labelonly.html?view=steve123
Styling:
- Modify the #labelDisplay CSS styles to customize appearance
- Background, text color, font size and animations can be adjusted
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta charset="UTF-8">
<title>VDO.Ninja - Connected Label</title>
<style>
body, html {
margin: 0;
padding: 0;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-family: Arial, sans-serif;
background-color: transparent;
width: 100%;
}
.electronDraggable {
-webkit-app-region: drag;
}
body > div {
-webkit-app-region: no-drag;
}
.hidden {
display: none;
opacity: 0;
}
#labelDisplay {
font-size: 48px;
font-weight: bold;
color: white;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
text-align: center;
transition: all 0.5s ease;
opacity: 0;
transform: translateY(20px);
}
#labelDisplay.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body class="electronDraggable">
<div id="labelDisplay" class="hidden"></div>
<script>
window.onerror = function(errorMsg, url, lineNumber) {
console.error(errorMsg, lineNumber);
return false;
};
function getById(id) {
return document.getElementById(id);
}
const urlParams = new URLSearchParams(window.location.search);
const roomID = urlParams.get("room") ? ("&scene&room="+urlParams.get("room")) : "";
const password = urlParams.get("password") ? ("&password="+urlParams.get("password")) : "";
const view = urlParams.get("view") ? ("&view="+urlParams.get("view")) : "";
let iframes = [];
let connectedPeers = {};
let currentDisplayedLabel = null;
function updateLabelDisplay() {
const labelDisplay = getById("labelDisplay");
const peerKeys = Object.keys(connectedPeers);
if (peerKeys.length > 0) {
const firstPeer = peerKeys[0];
const label = connectedPeers[firstPeer];
if (label !== currentDisplayedLabel) {
currentDisplayedLabel = label;
labelDisplay.textContent = currentDisplayedLabel;
labelDisplay.classList.remove("hidden");
// Force repaint
void labelDisplay.offsetWidth;
labelDisplay.classList.add("visible");
}
} else {
currentDisplayedLabel = null;
labelDisplay.classList.remove("visible");
setTimeout(() => {
labelDisplay.classList.add("hidden");
}, 500);
}
}
function RecvDataWindow(room) {
const iframe = document.createElement("iframe");
iframe.src = `https://vdo.ninja/?ln${password}${room}${view}&notmobile&label=overlaypage&vd=0&ad=0&novideo&noaudio&cleanoutput`;
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.position = "fixed";
iframe.style.left = "-100px";
iframe.style.top = "-100px";
iframe.id = `frame_${room}`;
iframe.allow = "midi;geolocation;microphone;";
iframes.push(iframe);
document.body.appendChild(iframe);
window.addEventListener("message", function(e) {
if (e.source !== iframe.contentWindow) return;
if ("action" in e.data && e.data.UUID) {
if (e.data.action === "push-connection" && "value" in e.data && !e.data.value) {
delete connectedPeers[e.data.UUID];
updateLabelDisplay();
} else if ((e.data.action === "push-connection-info" || e.data.action === "view-connection-info")
&& e.data.value && e.data.value.label) {
connectedPeers[e.data.UUID] = e.data.value.label;
updateLabelDisplay();
} else if (e.data.action === "view-connection" && "value" in e.data && !e.data.value) {
delete connectedPeers[e.data.UUID];
updateLabelDisplay();
} else if ("label" in e.data) {
connectedPeers[e.data.UUID] = e.data.label;
updateLabelDisplay();
}
}
});
}
if (roomID) {
roomID.split(",").forEach(room => {
RecvDataWindow(room.trim());
});
} else {
RecvDataWindow("");
}
</script>
</body>
</html>

View File

@@ -1523,10 +1523,6 @@ iframe {
.popup .menu { margin: 2px; }
.my-float {
margin-top: 7px;
opacity: 0.9;
}
.toggleSize {
font-size: 32px;
color: white;

View File

@@ -1,7 +1,7 @@
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/webmidi"></script>
<link rel="stylesheet" href="./main.css" />
<link rel="stylesheet" href="https://vdo.ninja/main.css" />
<style>
.container {
max-width: 80%;

View File

@@ -1,6 +1,6 @@
<html>
<head>
<title>IFRAME Example</title>
<title>Manual Mixer Sandbox - VDO.Ninja</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
@@ -448,4 +448,4 @@
<div id="container">
</div>
</body>
</html>
</html>

112
examples/multi.html Normal file
View File

@@ -0,0 +1,112 @@
<html>
<head><title>Multi-Room Monitor - VDO.Ninja</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
}
iframe {
width:100%;
height:470px;
border:0;
margin:0;
padding:0;
display:block;
}
</style>
</head>
<body>
<script>
(function(w) {
w.URLSearchParams = w.URLSearchParams || function(searchString) {
var self = this;
searchString = searchString.replace("??", "?");
self.searchString = searchString;
self.get = function(name) {
var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(self.searchString);
if (results == null) {
return null;
} else {
return decodeURI(results[1]) || 0;
}
};
};
})(window);
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
if (urlEdited !== window.location.search){
warnlog(window.location.search + " changed to " + urlEdited);
window.history.pushState({path: urlEdited.toString()}, '', urlEdited.toString());
}
var urlParams = new URLSearchParams(urlEdited);
var path = window.location.host+window.location.pathname.split("/").slice(0,-1).join("/");
var password = urlParams.get("passwords") || urlParams.get("password") || urlParams.get("pw") || urlParams.get("p") || null;
var rooms = "";
if (urlParams.has("rooms") || urlParams.has("room") || urlParams.has("r")){
rooms = urlParams.get("rooms") || urlParams.get("room") || urlParams.get("r");
rooms = rooms.split(",");
if (password == null){
password = prompt("Enter the password for the rooms; leave blank for none");
}
if (password){
password = password.split(",");
} else {
password = "";
}
for (var i = 0;i<rooms.length;i++){
var pass = "";
if (password && (password.length>i)){
pass = decodeURIComponent(password[i]);
if (pass){
pass = "&password="+pass;
}
} else if (password[0]){
pass = decodeURIComponent(password[0]);
if (pass){
pass = "&password="+pass;
}
}
loadIframes("https://"+path+"/../?clean&hidecodirectors&director="+rooms[i]+pass);
}
} else {
document.write("To use, comma separate the room names. ie: https://vdo.ninja/examples/multi?rooms=xxxx,yyy,ccc");
}
function loadIframes(url){
var iframe = document.createElement("iframe");
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
var params = window.location.search || "";
if (params.startsWith("?")){
params = params.slice(1);
iframe.src = url + "&" + params
} else {
iframe.src = url + params
}
document.body.appendChild(iframe);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,319 @@
<html>
<head>
<title>Mute Guest via IFRAME - VDO.Ninja</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color: #0000;
}
iframe {
border:0;
margin:0;
padding:0;
display:block;
width:100%;
height:90%
}
#viewlink {
width:400px;
}
#container {
display:block;
padding:0px;
padding:0px;
}
input{
padding:5px;
margin:5px;
}
button{
padding:5px;
margin:5px;
}
</style>
<script>
function loadIframe(){
document.getElementById("container").innerHTML = "";
var iframe = document.createElement("iframe");
var iframeContainer = document.createElement("div");
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
iframe.src = "../?dir=teststeve123&password=1234";
iframeContainer.appendChild(iframe);
document.getElementById("container").appendChild(iframeContainer);
var listOfStreamIDs = [
"1234_pov"
];
for (var i=0;i<listOfStreamIDs.length;i++){
var button = document.createElement("a");
button.innerHTML = "Invite "+listOfStreamIDs[i];
button.target = "_blank";
button.href = "../?room=teststeve123&password=1234&broadcast&transparent&autostart&nmb&nvb&gain=0&webcam&l=stevetest&push="+listOfStreamIDs[i];
iframeContainer.appendChild(button);
///////////////////
var button = document.createElement("button");
button.innerHTML = "speaker true "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "speaker",
value: true,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
var button = document.createElement("button");
button.innerHTML = "speaker false "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "speaker",
value: false,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
///////////////////
var button = document.createElement("button");
button.innerHTML = "display true "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "display",
value: true,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
var button = document.createElement("button");
button.innerHTML = "display false "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "display",
value: false,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
///////////////////
var button = document.createElement("button");
button.innerHTML = "MUTE true "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "mic",
value: true,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
var button = document.createElement("button");
button.innerHTML = "UN-MUTE "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "mic",
value: false,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
///////////////////
var button = document.createElement("button");
button.innerHTML = "addScene 1 toggle "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "addScene",
value: 1,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
var button = document.createElement("button");
button.innerHTML = "Scene 1 toggle "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "addScene",
value: "toggle",
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
var button = document.createElement("button");
button.innerHTML = "add Scene 1"+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "addScene",
value: true,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
var button = document.createElement("button");
button.innerHTML = "remove Scene 1"+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "addScene",
value: false,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
///////////////////
///////////////////
var button = document.createElement("button");
button.innerHTML = "MUTE SCENE "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "muteScene",
value: true,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
var button = document.createElement("button");
button.innerHTML = "un-mute Scene "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "muteScene",
value: false,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
///////////////////
var button = document.createElement("button");
button.innerHTML = "soloChat "+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "soloChat",
value: true,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
var button = document.createElement("button");
button.innerHTML = "soloChat off"+listOfStreamIDs[i];
button.dataset.sid = listOfStreamIDs[i];
button.onclick = function(){
iframe.contentWindow.postMessage({
action: "soloChat",
value: false,
target: this.dataset.sid
}, '*');
}; // target can be a stream ID or * for all.
iframeContainer.appendChild(button);
}
//////////// LISTEN FOR EVENTS
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
/// If you have a routing system setup, you could have just one global listener for all iframes instead.
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
if (typeof e.data !== "object"){return;}
if ("action" in e.data){
var outputWindow = document.createElement("div");
outputWindow.innerHTML = "event: "+e.data.action+"<br />";
outputWindow.style.border="1px dotted black";
iframeContainer.appendChild(outputWindow);
}
if ("streamIDs" in e.data){
var outputWindow = document.createElement("div");
outputWindow.innerHTML = "streamID list:<br />";
for (var key in e.data.streamIDs) {
outputWindow.innerHTML += "streamID: " + key + ", label:"+e.data.streamIDs[key] + "\n";
}
outputWindow.style.border="1px dotted black";
iframeContainer.appendChild(outputWindow);
}
});
}
</script>
</head>
<body>
<div id="container">
<button onclick="loadIframe();">Go to Directors Room</button>
<br />
The password for guests is 1234<br />
<br />
<br />
Custom guest invites and toggles for add/removing from scene=1 are on the bottom.
</div>
</body>
</html>

357
examples/noisegate.html Normal file
View File

@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Noise Gate Converter — Classic → "My Gate"</title>
<style>
:root {
--bg: #0e1116;
--panel: #171b23;
--muted: #9aa4b2;
--text: #e7edf3;
--accent: #6ee7b7;
--accent-2: #60a5fa;
--danger: #f87171;
}
* { box-sizing: border-box; }
body {
margin: 0; background: radial-gradient(1200px 600px at 20% -10%, #131826 0%, #0f1320 45%, var(--bg) 100%);
color: var(--text); font: 14px/1.35 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
.wrap { max-width: 1100px; margin: 32px auto; padding: 0 16px; }
header { display: flex; gap: 16px; align-items: center; justify-content: space-between; }
h1 { font-size: 22px; margin: 0; letter-spacing: 0.2px; }
.card { background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.0) 30%), var(--panel);
border: 1px solid rgba(255,255,255,.06); border-radius: 16px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
.grid { display: grid; grid-template-columns: 1.1fr 1fr; gap: 16px; }
.inputs { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 12px; }
label { display: block; font-weight: 600; color: #cdd6e1; margin-bottom: 6px; }
input[type="number"] { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,.08);
background: #0e1320; color: var(--text); outline: none; }
input[type="number"]:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(96,165,250,.25); }
.row { display: flex; gap: 10px; align-items: center; }
.muted { color: var(--muted); }
.pill { padding: 4px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 12.5px; }
pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
button { cursor: pointer; border: 0; padding: 10px 14px; border-radius: 12px; color: #0a0d12; background: var(--accent);
font-weight: 700; letter-spacing: .2px; }
button.secondary { background: #222836; color: #e8eef6; border: 1px solid rgba(255,255,255,.08); }
button.danger { background: var(--danger); color: #0a0d12; }
button:disabled { opacity: .6; cursor: not-allowed; }
.section-title { font-size: 13px; color: #a3b0c2; text-transform: uppercase; letter-spacing: .12em; margin: 16px 0 8px; }
/* meters */
.meters { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; }
.meter { position: relative; height: 16px; border-radius: 999px; background: #121826;
border: 1px solid rgba(255,255,255,.08); overflow: hidden; }
.meter .fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: linear-gradient(90deg, var(--accent-2), var(--accent));
transition: width .08s linear; }
.foot { margin-top: 14px; color: #90a1b6; font-size: 12.5px; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.code-block { background: #0b0f19; border: 1px solid rgba(255,255,255,.08); padding: 12px; border-radius: 12px; }
.caption { color: #9eb0c6; font-size: 12.5px; margin: 8px 0 2px; }
.kbd { font-family: ui-monospace, monospace; background: #0e1422; border: 1px solid rgba(255,255,255,.08); border-bottom-width: 2px; padding: 2px 6px; border-radius: 6px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Classic → <span class="pill mono">My Gate</span> converter</h1>
<div class="row">
<button id="startBtn" class="secondary">Start live test</button>
<button id="resetBtn" class="secondary" title="Reset to sensible defaults">Reset</button>
</div>
</header>
<div class="grid" style="margin-top: 16px;">
<section class="card">
<div class="section-title">Inputs — classic noise gate</div>
<div class="inputs">
<div>
<label for="thr">Threshold (dBFS)</label>
<input id="thr" type="number" step="1" min="-120" max="0" value="-50" />
<div class="muted" style="margin-top:6px">Linear amplitude: <span id="linThresh" class="mono">0.003162</span></div>
</div>
<div>
<label for="att">Attack (ms)</label>
<input id="att" type="number" step="1" min="0" max="2000" value="1" />
</div>
<div>
<label for="rel">Release (ms)</label>
<input id="rel" type="number" step="1" min="0" max="4000" value="100" />
</div>
<div>
<label for="hold">Hold (ms)</label>
<input id="hold" type="number" step="1" min="0" max="4000" value="10" />
</div>
<div>
<label for="range">Range / Depth (% reduction when closed)</label>
<input id="range" type="number" step="1" min="0" max="100" value="80" />
<div class="muted" style="margin-top:6px">Closed gain: <span id="downGain" class="mono">20%</span></div>
</div>
</div>
<div class="section-title">Live meter (optional)</div>
<div class="meters">
<div>
<div class="muted">Input level (approx dBFS)</div>
<div class="meter"><div id="inFill" class="fill" style="--v:0"></div></div>
</div>
<div>
<div class="muted">Gate gain (%)</div>
<div class="meter"><div id="gainFill" class="fill" style="--v:100"></div></div>
</div>
</div>
<div class="foot">Tip: set <span class="kbd">Range</span> for how deep your gate closes. Classic gates often call this <em>Range</em> or <em>Depth</em>. The rest maps 1:1.</div>
</section>
<section class="card">
<div class="section-title">Outputs — what your code expects</div>
<div class="cols">
<div>
<div class="caption">Calls you make when the detector toggles</div>
<div class="code-block mono" id="callsBlock">
<pre>// gate CLOSE (after Hold):
<span id="gateDownCall">changeGatingGain(20, 100)</span>
// gate OPEN (on speech):
<span id="gateUpCall">changeGatingGain(100, 1)</span></pre>
</div>
</div>
<div>
<div class="caption">URL params / compact settings string</div>
<div class="code-block mono">
<pre>?noisegate=1&amp;noisegatesettings=<span id="settingsStr">-50,1,100,10,20</span></pre>
</div>
<div class="caption">Individual values</div>
<div class="code-block mono" id="kv">
<pre>{
thresholdDb: <span id="kv_thr">-50</span>,
attackMs: <span id="kv_att">1</span>,
releaseMs: <span id="kv_rel">100</span>,
holdMs: <span id="kv_hold">10</span>,
closedGainPercent: <span id="kv_closed">20</span>
}</pre>
</div>
<div class="row" style="margin-top:8px; gap:8px;">
<button class="secondary" id="copyUrl">Copy URL</button>
<button class="secondary" id="copyCalls">Copy calls</button>
</div>
</div>
</div>
<div class="section-title">Dropin helper (optional)</div>
<div class="code-block mono" style="max-height: 320px; overflow: auto;">
<pre id="helperCode">// Convert classic gate knobs to your changeGatingGain() usage.
function classicalToMyGate({ thresholdDb, attackMs, releaseMs, holdMs, rangePct = 80 }) {
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const downGainPercent = 100 - clamp(rangePct, 0, 100); // % of full when closed
const linearThreshold = Math.pow(10, thresholdDb / 20);
return {
thresholdDb,
linearThreshold, // for detector if you need it
attackMs: Math.max(0, attackMs|0),
releaseMs: Math.max(0, releaseMs|0),
holdMs: Math.max(0, holdMs|0),
downGainPercent, // e.g., 20 means -80% depth
gateDownCall: `changeGatingGain(${downGainPercent}, ${releaseMs|0})`,
gateUpCall: `changeGatingGain(100, ${attackMs|0})`,
// compact string your app can parse
noisegatesettings: [thresholdDb, attackMs|0, releaseMs|0, holdMs|0, downGainPercent|0].join(',')
};
}
// Simple detector that drives your changeGatingGain() based on an AnalyserNode.
// Uses threshold + hold; release/attack are handled by changeGatingGain ramps.
function wireClassicGateDetector({ analyser, audioCtx, thresholdDb, holdMs, attackMs, releaseMs, downGainPercent }) {
const buf = new Float32Array(analyser.fftSize);
let state = 'open';
let holdUntil = 0;
function setGainPct(percent, ms) {
const t = audioCtx.currentTime;
const v = percent / 100;
try {
// mirrors your changeGatingGain() behavior
analyser.disconnect; // no-op keeps linter happy
window.changeGatingGain ? window.changeGatingGain(percent, ms) : null;
} catch (e) {}
}
function tick() {
analyser.getFloatTimeDomainData(buf);
let s = 0; for (let i = 0; i < buf.length; i++) s += buf[i] * buf[i];
const rms = Math.sqrt(s / buf.length) + 1e-12;
const db = 20 * Math.log10(rms);
const now = performance.now();
if (db > thresholdDb) {
holdUntil = now + holdMs;
if (state !== 'open') { setGainPct(100, attackMs); state = 'open'; }
} else if (now > holdUntil) {
if (state !== 'closed') { setGainPct(100 - downGainPercent, releaseMs); state = 'closed'; }
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
</pre>
</div>
</section>
</div>
</div>
<script>
// ==== Utility ==== //
const $ = (id) => document.getElementById(id);
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const dbToLin = (db) => Math.pow(10, db / 20);
function compute() {
const thr = parseFloat($("thr").value || -50);
const att = parseInt($("att").value || 0, 10);
const rel = parseInt($("rel").value || 0, 10);
const hold = parseInt($("hold").value || 0, 10);
const range = clamp(parseInt($("range").value || 80, 10), 0, 100);
const downGainPercent = 100 - range; // % of full volume when closed
const cfg = {
thresholdDb: thr,
linearThreshold: dbToLin(thr),
attackMs: att, releaseMs: rel, holdMs: hold,
downGainPercent
};
$("linThresh").textContent = cfg.linearThreshold.toFixed(6);
$("downGain").textContent = (cfg.downGainPercent).toFixed(0) + '%';
$("gateDownCall").textContent = `changeGatingGain(${cfg.downGainPercent}, ${cfg.releaseMs})`;
$("gateUpCall").textContent = `changeGatingGain(100, ${cfg.attackMs})`;
$("settingsStr").textContent = [cfg.thresholdDb, cfg.attackMs, cfg.releaseMs, cfg.holdMs, cfg.downGainPercent].join(',');
$("kv_thr").textContent = cfg.thresholdDb;
$("kv_att").textContent = cfg.attackMs;
$("kv_rel").textContent = cfg.releaseMs;
$("kv_hold").textContent = cfg.holdMs;
$("kv_closed").textContent = cfg.downGainPercent;
return cfg;
}
// bind inputs
Array.from(document.querySelectorAll('input')).forEach(i => i.addEventListener('input', compute));
compute();
// Copy helpers
$("copyUrl").addEventListener('click', () => {
const s = `?noisegate=1&noisegatesettings=${$("settingsStr").textContent}`;
navigator.clipboard.writeText(s);
notify('Copied URL params');
});
$("copyCalls").addEventListener('click', () => {
const s = `${$("gateDownCall").textContent}\n${$("gateUpCall").textContent}\n`;
navigator.clipboard.writeText(s);
notify('Copied function calls');
});
function notify(msg) {
const el = document.createElement('div');
el.textContent = msg; el.style.position = 'fixed'; el.style.right = '16px'; el.style.bottom = '16px';
el.style.background = 'rgba(20,26,37,.95)'; el.style.border = '1px solid rgba(255,255,255,.08)'; el.style.padding = '10px 12px'; el.style.borderRadius = '10px';
document.body.appendChild(el); setTimeout(() => el.remove(), 1200);
}
$("resetBtn").addEventListener('click', () => {
$("thr").value = -50; $("att").value = 1; $("rel").value = 100; $("hold").value = 10; $("range").value = 80; compute();
});
// ==== Live test (optional) ==== //
let ctx, src, gateNode, analyser, raf, buf;
let state = 'open', holdUntil = 0;
const inFill = $("inFill"), gainFill = $("gainFill");
async function start() {
if (ctx) return stop();
try {
ctx = new (window.AudioContext || window.webkitAudioContext)();
const stream = await navigator.mediaDevices.getUserMedia({audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false }});
src = ctx.createMediaStreamSource(stream);
gateNode = ctx.createGain(); gateNode.gain.value = 1.0;
src.connect(gateNode);
analyser = ctx.createAnalyser(); analyser.fftSize = 2048; buf = new Float32Array(analyser.fftSize);
gateNode.connect(analyser); gateNode.connect(ctx.destination);
loop();
$("startBtn").textContent = 'Stop live test';
} catch (e) {
notify('Mic permission denied or unavailable');
}
}
function stop() {
cancelAnimationFrame(raf); raf = null;
if (ctx) { try { ctx.close(); } catch (e) {} }
ctx = src = gateNode = analyser = null; state = 'open';
inFill.style.width = '0%'; gainFill.style.width = '100%';
$("startBtn").textContent = 'Start live test';
}
function setGainPct(pct, ms) {
if (!gateNode) return;
const t = ctx.currentTime; const v = clamp(pct,0,100)/100;
try {
gateNode.gain.cancelScheduledValues(t);
gateNode.gain.setValueAtTime(gateNode.gain.value, t);
gateNode.gain.linearRampToValueAtTime(v, t + Math.max(0, ms)/1000);
} catch (e) {
gateNode.gain.value = v;
}
}
function loop() {
const cfg = compute();
analyser.getFloatTimeDomainData(buf);
let s = 0; for (let i = 0; i < buf.length; i++) s += buf[i]*buf[i];
const rms = Math.sqrt(s/buf.length) + 1e-12;
const db = 20*Math.log10(rms);
// simple display: map -100..0 dB to 0..100
const inPct = clamp(100 + db, 0, 100);
inFill.style.width = inPct + '%';
const now = performance.now();
if (db > cfg.thresholdDb) {
holdUntil = now + cfg.holdMs;
if (state !== 'open') { setGainPct(100, cfg.attackMs); state = 'open'; }
} else if (now > holdUntil) {
if (state !== 'closed') { setGainPct(cfg.downGainPercent, cfg.releaseMs); state = 'closed'; }
}
const gv = gateNode.gain.value * 100; gainFill.style.width = clamp(gv, 0, 100) + '%';
raf = requestAnimationFrame(loop);
}
$("startBtn").addEventListener('click', () => ctx ? stop() : start());
// Expose the core converter globally in case you want to copy it out via DevTools
window.classicalToMyGate = function(args){
const range = clamp(args.rangePct ?? 80, 0, 100);
const downGainPercent = 100 - range;
return {
thresholdDb: args.thresholdDb,
linearThreshold: dbToLin(args.thresholdDb),
attackMs: args.attackMs|0,
releaseMs: args.releaseMs|0,
holdMs: args.holdMs|0,
downGainPercent,
gateDownCall: `changeGatingGain(${downGainPercent}, ${args.releaseMs|0})`,
gateUpCall: `changeGatingGain(100, ${args.attackMs|0})`,
noisegatesettings: [args.thresholdDb, args.attackMs|0, args.releaseMs|0, args.holdMs|0, downGainPercent|0].join(',')
};
};
</script>
</body>
</html>

View File

@@ -328,12 +328,22 @@
document.getElementById("setup").style.display = "none";
scenesData = data;
updateSceneList();
var pathname = window.location.pathname.split("/");
pathname.pop();
pathname = pathname.join("/");
var clientLink = window.location.protocol + "//" + window.location.host + pathname + "/interface.html?room="+roomname+"&password="+pwurl;
document.getElementById("client").href = clientLink;
document.getElementById("client").innerHTML = "<b><font style='color:#70c4ff;'>client link:</font></b> "+clientLink;
var pathname = window.location.pathname.split("/");
pathname.pop();
pathname = pathname.join("/");
var clientLink = window.location.protocol + "//" + window.location.host + pathname + "/interface.html?room="+encodeURIComponent(roomname)+"&password="+encodeURIComponent(pwurl);
var clientAnchor = document.getElementById("client");
clientAnchor.href = clientLink;
clientAnchor.textContent = "";
clientAnchor.target = "_blank";
clientAnchor.rel = "noopener";
var labelBold = document.createElement("b");
var labelFont = document.createElement("span");
labelFont.style.color = "#70c4ff";
labelFont.textContent = "client link:";
labelBold.appendChild(labelFont);
clientAnchor.appendChild(labelBold);
clientAnchor.appendChild(document.createTextNode(" " + clientLink));
document.getElementById("info").innerHTML = "<br /><p style='color:#bdffbd;'>Connection to OBS websockets opened.</p>" + document.getElementById("info").innerHTML;
try {
obs._socket.onmessage2 = obs._socket.onmessage; // hijacking the obs-websocket.js framework
@@ -398,4 +408,4 @@
</script>
</body>
</html>
</html>

440
examples/obsremote.html Normal file
View File

@@ -0,0 +1,440 @@
<html>
<head>
<title>
Remote control interface using VDO.Ninja's iframe API
</title>
<style>
#debugRemoteOBSControl{
font-size:50%;
}
button {
margin: 10px;
padding: 20px;
}
a {
display: inline-block;
}
.pressed{
border: solid 3px black;
}
</style>
</head>
<body>
<div id="remoteOBSControl" class="customModelPopup" >
<h3 data-translate="remote-control-obs-menu">Remote Controller for OBS Studio</h3><br />
<div id="obsControlHelp" class="hidden" style="margin: 10px 0;display:block" >
No remote controllable instances of OBS Studio were found
</div>
<div id="obsControlButtons" style="margin: 10px 0;display:block" >
</div>
<div id="obsSceneNames" style="margin: 10px 0;display:block">
</div>
<div id="obsRemotePassword" class="hidden" style="margin: 10px 0;display:block;" >
<span style="font-size:117%"><i class="las la-key" style="margin: 10px;"></i>Remote OBS passcode:</span>
<input id="obsRemotePasswordinput" style="margin:0 10px;display:inline-block;padding: 8px 10px 6px 10px;" onchange="changeremote()" oninput="changeremote()" placeholder="Enter the remote OBS password here" />
</div>
Put the following link into OBS with permissions set to allow for scene changes:<br ><a href="" id="putintoOBSlink" style="margin: 10px 0;" target="_blank"></a>
<small style="margin: 20px 0 0 0;display:block;" >
See the <a href="https://docs.vdo.ninja/advanced-settings/upcoming-parameters/and-obs" style="cursor:pointer;" target="_blank">documentation</a> for the built-in OBS control options inside VDO.Ninja
</small>
<div id="debugRemoteOBSControl" class="hidden">
</div>
</div>
<script>
var sessionID = '';
var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var charactersLength = characters.length;
for (var i = 0; i < 12; i++) {
sessionID += characters.charAt(Math.floor(Math.random() * charactersLength));
}
(function(w) {
w.URLSearchParams = w.URLSearchParams || function(searchString) {
var self = this;
searchString = searchString.replace("??", "?");
self.searchString = searchString;
self.get = function(name) {
var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(self.searchString);
if (results == null) {
return null;
} else {
return decodeURI(results[1]) || 0;
}
};
};
})(window);
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
var urlParams = new URLSearchParams(urlEdited);
if (urlParams.has("id") || urlParams.has("push") || urlParams.has("streamid") || urlParams.has("session")){
sessionID = urlParams.get("id") || urlParams.get("push") || urlParams.get("streamid") || urlParams.get("session") || sessionID;
}
var iframe = document.createElement("iframe");
var iframeContainer = document.createElement("div");
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
function changeremote(){
var ele = document.getElementById("obsRemotePasswordinput");
var val = ele.value.replace(/[^0-9a-zA-Z]/g, '');
ele.value = val;
if (val){
document.getElementById("putintoOBSlink").innerText = "https://vdo.ninja/?view="+sessionID+"&dataonly&remote="+val;
document.getElementById("putintoOBSlink").href = "https://vdo.ninja/?view="+sessionID+"&dataonly&remote="+val;
} else {
document.getElementById("putintoOBSlink").innerText = "https://vdo.ninja/?view="+sessionID+"&dataonly&remote";
document.getElementById("putintoOBSlink").href = "https://vdo.ninja/?view="+sessionID+"&dataonly&remote";
}
}
changeremote();
iframe.src = "https://vdo.ninja/?push="+sessionID+"&remote&dataonly"; ///// change this
iframeContainer.appendChild(iframe);
iframeContainer.style.width = "300px";
iframeContainer.style.height = "20px";
iframeContainer.style.overflow = "hidden";
document.body.appendChild(iframeContainer);
//////////// LISTEN FOR EVENTS
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
if (typeof e.data !== "object"){errorlog(e);return;}
warnlog(e.data);
if ("action" in e.data){
if (e.data.value && (e.data.action === "obs-state")){
manageSceneState({obsState: e.data.value}, e.data.UUID)
} else if (e.data.action == "new-push-connection"){
if (e.data.UUID){
if (!e.data.value){
delete session.pcs[e.data.UUID];
document.querySelectorAll("[data-system='"+e.data.UUID+"']").forEach(ele=>{
ele.remove();
});
}
}
}
}
});
var session = {};
session.pcs = {}
function errorlog(e,l=null){
console.error(e,l);
}
function warnlog(e,l=null){
console.warn(e,l);
}
function log(e,l=null){
console.log(e,l);
}
function getById(ele){
return document.getElementById(ele) || document.createElement("span");
}
function sendMessage(msg){
if (iframe){
iframe.contentWindow.postMessage(msg, '*');
}
}
function manageSceneState(data, UUID){ // incoming obs details
var processNeeded = false
if (!(UUID in session.pcs)){ // re-using vdo.ninja state structures, to make code functions available as simply copy/paste
session.pcs[UUID] = {};
session.pcs[UUID].obsState = {};
}
try{
if (data.obsState){
if ("sourceActive" in data.obsState){
processNeeded=true;
session.pcs[UUID].obsState.sourceActive = data.obsState.sourceActive;
}
if ("visibility" in data.obsState){
processNeeded=true;
session.pcs[UUID].obsState.visibility = data.obsState.visibility;
}
if ("details" in data.obsState){
processNeeded=true;
session.pcs[UUID].obsState.details = data.obsState.details;
}
if ("streaming" in data.obsState){
processNeeded=true;
session.pcs[UUID].obsState.streaming = data.obsState.streaming;
}
if ("recording" in data.obsState){
processNeeded=true;
session.pcs[UUID].obsState.recording = data.obsState.recording;
}
if ("virtualcam" in data.obsState){
processNeeded=true;
session.pcs[UUID].obsState.virtualcam = data.obsState.virtualcam;
}
}
} catch(e){
errorlog(e);
}
if (!processNeeded){
return;
}
try {
var control = 0;
if (session.pcs[UUID].obsState && session.pcs[UUID].obsState.details){
control = parseInt(session.pcs[UUID].obsState.details.controlLevel) || 0; //0 for NONE, 1 for READ_OBS (OBS data), 2 for READ_USER (User data), 3 for BASIC, 4 for ADVANCED and 5 for ALL
}
var multi = false;
getById("obsControlButtons").querySelectorAll("[data-system]").forEach(ele=>{
if (ele.dataset.system in session.pcs){
if (ele.dataset.system !==UUID){
multi = true;
}
} else { // delete, since no longer active.
ele.remove();
}
});
getById("obsSceneNames").querySelectorAll("[data-system]").forEach(ele=>{
if (ele.dataset.system in session.pcs){
if (ele.dataset.system !==UUID){
multi = true;
}
} else { // delete, since no longer active.
ele.remove();
}
});
if (control==0){
var obsControlButtonsBox = getById("obsControlButtons").querySelector("[data-system='"+UUID+"']");
if (obsControlButtonsBox){
obsControlButtonsBox.remove();
}
var obsSceneNamesBox = getById("obsSceneNames").querySelector("[data-system='"+UUID+"']"); // this hides if less than 2, so hide it now.
if (obsSceneNamesBox){
obsSceneNamesBox.remove();
}
if (!multi){
getById("obsControlHelp").classList.remove("hidden");
}
return;
}
getById("obsControlHelp").classList.add("hidden");
var obsControlButtonsBox = getById("obsControlButtons").querySelector("[data-system='"+UUID+"']");
if (!obsControlButtonsBox){
obsControlButtonsBox = document.createElement("div");
obsControlButtonsBox.dataset.system = UUID;
getById("obsControlButtons").appendChild(obsControlButtonsBox);
} else {
obsControlButtonsBox.innerHTML = "";
}
if (multi){
var h3 = document.createElement("h3");
h3.innerText = "OBS instance: " + (session.pcs[UUID].label || session.pcs[UUID].scene || UUID);
obsControlButtonsBox.appendChild(h3);
}
if (session.pcs[UUID].obsState && ("streaming" in session.pcs[UUID].obsState)){
var controlButton = document.createElement("button");
controlButton.dataset.UUID = UUID;
if (session.pcs[UUID].obsState.streaming){
controlButton.classList.add("pressed");
controlButton.ariaPressed = "true";
controlButton.dataset.obsAction = "stopStreaming";
controlButton.innerText = "📡 stop streaming";
} else {
controlButton.dataset.obsAction = "startStreaming";
controlButton.innerText = "📡 start streaming";
}
if (control<5){
controlButton.disabled = true;
controlButton.style.cursor = "not-allowed";
controlButton.title = "Source is lacking required permissions.";
} else {
controlButton.onclick = async function(){
var msg = {};
msg.obsCommand = {}
msg.obsCommand.action = this.dataset.obsAction;
msg.UUID = this.dataset.UUID;
if (document.querySelector("#obsRemotePassword>input") && document.querySelector("#obsRemotePassword>input").value){
msg.remote = document.querySelector("#obsRemotePassword>input").value;
} else {
msg.remote = getById("obsRemotePassword").value || false;
}
sendMessage(msg);
log("action request: "+this.dataset.obsAction);
}
}
obsControlButtonsBox.appendChild(controlButton);
}
if (session.pcs[UUID].obsState && ("recording" in session.pcs[UUID].obsState)){
var controlButton = document.createElement("button");
controlButton.dataset.UUID = UUID;
if (session.pcs[UUID].obsState.recording){
controlButton.classList.add("pressed");
controlButton.ariaPressed = "true";
controlButton.dataset.obsAction = "stopRecording";
controlButton.innerText = "📽 stop recording";
} else {
controlButton.dataset.obsAction = "startRecording";
controlButton.innerText = "📽 start recording";
}
if (control<5){
controlButton.disabled = true;
controlButton.style.cursor = "not-allowed";
controlButton.title = "Source is lacking required permissions.";
} else {
controlButton.onclick = async function(){
var msg = {};
msg.obsCommand = {};
msg.obsCommand.action = this.dataset.obsAction;
msg.UUID = this.dataset.UUID;
if (document.querySelector("#obsRemotePassword>input").value){
msg.remote = document.querySelector("#obsRemotePassword>input").value;
} else {
msg.remote = getById("obsRemotePassword").value || false;
}
sendMessage(msg);
log("action request: "+this.dataset.obsAction);
}
}
obsControlButtonsBox.appendChild(controlButton);
}
if (session.pcs[UUID].obsState && ("virtualcam" in session.pcs[UUID].obsState)){
var controlButton = document.createElement("button");
controlButton.dataset.UUID = UUID;
if (session.pcs[UUID].obsState.virtualcam){
controlButton.classList.add("pressed");
controlButton.ariaPressed = "true";
controlButton.dataset.obsAction = "stopVirtualcam";
controlButton.innerText = "💻 stop virtualcam";
} else {
controlButton.dataset.obsAction = "startVirtualcam";
controlButton.innerText = "💻 start virtualcam";
}
if (control<5){
controlButton.disabled = true;
controlButton.style.cursor = "not-allowed";
controlButton.title = "Source is lacking required permissions.";
} else {
controlButton.onclick = async function(){
var msg = {};
msg.obsCommand = {}
msg.obsCommand.action = this.dataset.obsAction;
msg.UUID = this.dataset.UUID;
if (document.querySelector("#obsRemotePassword>input").value){
msg.remote = document.querySelector("#obsRemotePassword>input").value;
} else {
msg.remote = getById("obsRemotePassword").value || false;
}
sendMessage(msg);
log("action request: "+this.dataset.obsAction);
}
}
obsControlButtonsBox.appendChild(controlButton);
}
} catch(e){errorlog(e);} // just in case the client has disconnected.
if (control<2){
var obsSceneNamesBox = getById("obsSceneNames").querySelector("[data-system='"+UUID+"']");
if (obsSceneNamesBox){
obsSceneNamesBox.remove();
}
return;
}
var obsSceneNamesBox = getById("obsSceneNames").querySelectorAll("div[data-system='"+UUID+"']");
if (!obsSceneNamesBox.length){
obsSceneNamesBox = document.createElement("div");
obsSceneNamesBox.dataset.system = UUID;
getById("obsSceneNames").appendChild(obsSceneNamesBox);
} else {
obsSceneNamesBox = obsSceneNamesBox[0];
obsSceneNamesBox.innerHTML = "";
}
if (multi){
var h3 = document.createElement("h3");
h3.innerText = "OBS instance: " + (session.pcs[UUID].label || session.pcs[UUID].scene || UUID);
obsSceneNamesBox.appendChild(h3);
}
if (session.pcs[UUID].obsState.details){
var details = session.pcs[UUID].obsState.details;
if (details.scenes){
details.scenes.forEach(scene=>{
var sceneButton = document.createElement("button");
sceneButton.dataset.obsScene = scene;
sceneButton.dataset.UUID = UUID;
sceneButton.innerText = scene;
if (details.currentScene && details.currentScene.name && (details.currentScene.name === scene)){
sceneButton.classList.add("pressed");
sceneButton.ariaPressed = "true";
}
obsSceneNamesBox.appendChild(sceneButton);
if (control<4){
sceneButton.disabled = true;
sceneButton.style.cursor = "not-allowed";
sceneButton.title = "Source is lacking required permissions.";
} else {
sceneButton.onclick = async function(){
var msg = {};
msg.obsCommand = {action: "setCurrentScene", value: this.dataset.obsScene};
msg.UUID = this.dataset.UUID;
if (document.querySelector("#obsRemotePassword>input").value){
msg.remote = document.querySelector("#obsRemotePassword>input").value;
} else {
msg.remote = getById("obsRemotePassword").value || false;
}
sendMessage(msg);
log("scene change request: "+this.dataset.obsScene);
};
}
});
}
}
getById("debugRemoteOBSControl").innerText = JSON.stringify(session.pcs[UUID].obsState);
}
</script>
</body>
</html>

146
examples/overlay.html Normal file
View File

@@ -0,0 +1,146 @@
<html>
<head><title>overlay + Video</title>
<meta name="viewport" content="width=device-width, initial-scale=0.7, maximum-scale=1.0, user-scalable=yes" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
color:white;
}
iframe {
width:100vw;
height:100vh;
border:0;
margin:0;
padding:0;
position:fixed;
top:0;
left:0
}
input{
padding:10px;
width:80%;
font-size:1.2em;
z-index: 1000;
}
h1{
color: white;
font-family: verdana;
margin: 10px;
}
</style>
</head>
<body>
<div id="clean">
<h1>Apply an Overlay to VDO.Ninja</h1>
<input placeholder="Enter a VDON URL here" id="viewlink" type="text" />
<input placeholder="Enter the Overlay page here" id="overlay" type="text" />
<button onclick="loadIframes()" style="display:block;padding:10px;margin:10px;">START</button>(Leave blank and press start to see a default sample result)
</div>
<script>
window.addEventListener("orientationchange", function() {
// Announce the new orientation number
// alert(window.orientation);
}, false);
function removeStorage(cname){
localStorage.removeItem(cname);
}
function setStorage(cname, cvalue, hours=9999){ // not actually a cookie
var now = new Date();
var item = {
value: cvalue,
expiry: now.getTime() + (hours * 60 * 60 * 1000),
};
try{
localStorage.setItem(cname, JSON.stringify(item));
}catch(e){errorlog(e);}
}
function getStorage(cname) {
try {
var itemStr = localStorage.getItem(cname);
} catch(e){
errorlog(e);
return;
}
if (!itemStr) {
return "";
}
var item = JSON.parse(itemStr);
var now = new Date();
if (now.getTime() > item.expiry) {
localStorage.removeItem(cname);
return "";
}
return item.value;
}
if (getStorage("overlayChatLink")){
document.getElementById("overlay").value = getStorage("overlayChatLink");
}
if (getStorage("vdoNinjaoverlayURL")){
document.getElementById("viewlink").value = getStorage("vdoNinjaoverlayURL");
}
function loadIframes(url=false){
var roomname = document.getElementById("viewlink").value;
var overlay = document.getElementById("overlay").value;
document.getElementById("clean").parentNode.removeChild(document.getElementById("clean"));
if (!roomname){
var room1 = "../";
} else if (roomname.startsWith("https://")){
var room1 = roomname;
} else {
var room1 = "https://"+roomname;
}
var iframe = document.createElement("iframe");
iframe.allow = "document-domain;encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
iframe.src = room1;
document.body.appendChild(iframe);
if (!overlay){
var room2 = "./test_overlay";
} else if (overlay.startsWith("https://")){
var room2 = overlay;
} else {
var room2 = "https://"+overlay;
}
var iframe = document.createElement("iframe");
iframe.allow = "document-domain;encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
iframe.src = room2;
iframe.style.pointerEvents = "none";
iframe.style.backgroundColor = "#0000";
iframe.style.width = "25vw";
iframe.style.height = "25vh";
iframe.style.overflow = "hidden";
document.body.appendChild(iframe);
if (roomname && overlay){
setStorage("overlayChatLink", room2);
setStorage("vdoNinjaoverlayURL", room1);
}
}
</script>
</body>
</html>

648
examples/p2pdrawing.md Normal file
View File

@@ -0,0 +1,648 @@
# VDO.Ninja IFRAME API: Transmitting Drawing Data Between Clients
This guide explains how to use the VDO.Ninja IFRAME API to send drawing data (or any custom data) between clients using peer-to-peer (P2P) data channels.
## Understanding the Data Channel
VDO.Ninja allows you to send arbitrary data between connected clients using its P2P data channels. This feature enables applications like:
- Custom drawing/annotation tools
- Chat systems
- Control signals
- Sensor data exchange
- Any other custom data payloads
The creators of VDO.Ninja use VDO.Ninja's data-channel functionality in many of their other applications and services, including Social Stream Ninja that processes hundreds of messages per minute per peer connection.
## Basic Setup
First, set up your VDO.Ninja iframe as described in the basic documentation:
```javascript
// Create the iframe element
var iframe = document.createElement("iframe");
// Set necessary permissions
iframe.allow = "camera;microphone;fullscreen;display-capture;autoplay;";
// Set the source URL (your VDO.Ninja room)
iframe.src = "https://vdo.ninja/?room=your-room-name&cleanoutput";
// Add the iframe to your page
document.getElementById("container").appendChild(iframe);
```
## Setting Up Event Listeners
To receive data from other clients, set up an event listener for messages from the iframe:
```javascript
// Set up event listener (cross-browser compatible)
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
// Connected peers storage
var connectedPeers = {};
// Add the event listener
eventer(messageEvent, function(e) {
// Make sure the message is from our VDO.Ninja iframe
if (e.source != iframe.contentWindow) return;
// Process connection events to track connected peers
if ("action" in e.data) {
if (e.data.action === "guest-connected" && e.data.streamID) {
// Store connected peer information
connectedPeers[e.data.streamID] = e.data.value?.label || "Guest";
console.log("Guest connected:", e.data.streamID, "Label:", connectedPeers[e.data.streamID]);
}
else if (e.data.action === "push-connection" && e.data.value === false && e.data.streamID) {
// Remove disconnected peers
console.log("Guest disconnected:", e.data.streamID);
delete connectedPeers[e.data.streamID];
}
}
// Handle received data
if ("dataReceived" in e.data) {
// Process any custom data received from peers
console.log("Data received:", e.data.dataReceived);
// If our custom data format is detected
if ("overlayNinja" in e.data.dataReceived) {
processReceivedData(e.data.dataReceived.overlayNinja, e.data.UUID);
}
}
}, false);
function processReceivedData(data, senderUUID) {
// Process the data based on your application's needs
console.log("Processing data from UUID:", senderUUID, "Data:", data);
// Example: Handle drawing data
if (data.drawingData) {
updateDrawingCanvas(data.drawingData);
}
}
```
## Sending Data to Peers
### Sending to All Connected Peers
Use this approach to broadcast data to all connected peers:
```javascript
function sendDataToAllPeers(data) {
// Create the data structure
var payload = {
drawingData: data // Your custom drawing data
};
// Send to all peers
iframe.contentWindow.postMessage({
sendData: { overlayNinja: payload },
type: "pcs" // Use peer connection for reliability
}, "*");
}
```
### Sending to a Specific Peer by UUID
Use this approach to send data to a specific peer identified by UUID:
```javascript
function sendDataToPeer(data, targetUUID) {
// Create the data structure
var payload = {
drawingData: data // Your custom drawing data
};
// Send to specific UUID
iframe.contentWindow.postMessage({
sendData: { overlayNinja: payload },
type: "pcs",
UUID: targetUUID
}, "*");
}
```
### Sending to Peers with Specific Labels
Use this approach to send data to all peers with a specific label:
```javascript
function sendDataByLabel(data, targetLabel) {
// Create the data structure
var payload = {
drawingData: data // Your custom drawing data
};
// Iterate through connected peers to find those with matching label
var keys = Object.keys(connectedPeers);
for (var i = 0; i < keys.length; i++) {
try {
var UUID = keys[i];
var label = connectedPeers[UUID];
if (label === targetLabel) {
// Send to this specific peer
iframe.contentWindow.postMessage({
sendData: { overlayNinja: payload },
type: "pcs",
UUID: UUID
}, "*");
}
} catch (e) {
console.error("Error sending to peer:", e);
}
}
}
```
### Sending to a Peer by StreamID
Use this approach when you know the streamID but not the UUID:
```javascript
function sendDataByStreamID(data, streamID) {
// Create the data structure
var payload = {
drawingData: data // Your custom drawing data
};
// Send to specific streamID
iframe.contentWindow.postMessage({
sendData: { overlayNinja: payload },
type: "pcs",
streamID: streamID
}, "*");
}
```
## Drawing-Specific Implementation
For transmitting drawing data specifically, you'll need to:
1. Create a drawing canvas on your page
2. Capture drawing events
3. Format the data appropriately
4. Send the data to peers
5. Process and render received drawing data
Here's a simplified example:
```javascript
// 1. Set up a drawing canvas
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 480;
document.getElementById('drawing-container').appendChild(canvas);
const ctx = canvas.getContext('2d');
// Drawing state
let isDrawing = false;
let lastX = 0;
let lastY = 0;
let currentPath = [];
// 2. Capture drawing events
canvas.addEventListener('mousedown', (e) => {
isDrawing = true;
[lastX, lastY] = [e.offsetX, e.offsetY];
// Start a new path
currentPath = [];
// Normalize coordinates (0-1 range)
const point = {
x: lastX / canvas.width,
y: lastY / canvas.height
};
currentPath.push(point);
});
canvas.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
// Draw locally
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
// Store normalized point
const point = {
x: e.offsetX / canvas.width,
y: e.offsetY / canvas.height
};
currentPath.push(point);
[lastX, lastY] = [e.offsetX, e.offsetY];
});
canvas.addEventListener('mouseup', () => {
if (isDrawing) {
isDrawing = false;
// 3 & 4. Format and send the path data
if (currentPath.length > 1) {
// Send the complete path
sendDrawingData(currentPath);
}
// Reset current path
currentPath = [];
}
});
// Send drawing data to all peers
function sendDrawingData(pathPoints) {
// Format the data as a path
const drawingData = {
t: 'path', // type: path
p: pathPoints
};
// Send to all peers
iframe.contentWindow.postMessage({
sendData: { overlayNinja: { drawingData: drawingData } },
type: "pcs"
}, "*");
}
// 5. Process received drawing data
function processReceivedData(data, senderUUID) {
if (data.drawingData && data.drawingData.t === 'path') {
const pathPoints = data.drawingData.p;
// Render the received path
if (pathPoints && pathPoints.length > 1) {
ctx.beginPath();
// Convert normalized coordinates back to canvas coordinates
const startX = pathPoints[0].x * canvas.width;
const startY = pathPoints[0].y * canvas.height;
ctx.moveTo(startX, startY);
for (let i = 1; i < pathPoints.length; i++) {
const x = pathPoints[i].x * canvas.width;
const y = pathPoints[i].y * canvas.height;
ctx.lineTo(x, y);
}
ctx.stroke();
}
}
}
```
## Advanced Drawing Commands
You can implement special drawing commands like clear, undo, etc.:
```javascript
// Clear the drawing canvas
function clearDrawing() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Send clear command to all peers
iframe.contentWindow.postMessage({
sendData: { overlayNinja: { drawingData: "clear" } },
type: "pcs"
}, "*");
}
// Undo last drawing action
function undoLastDrawing() {
// Local undo logic...
// Send undo command to all peers
iframe.contentWindow.postMessage({
sendData: { overlayNinja: { drawingData: "undo" } },
type: "pcs"
}, "*");
}
```
## Using VDO.Ninja's Built-in Drawing System
VDO.Ninja has a built-in drawing system you can leverage if you prefer not to implement your own:
```javascript
// Send drawing data using VDO.Ninja's built-in format
function sendVDONinjaDrawing(drawingData) {
iframe.contentWindow.postMessage({
draw: drawingData, // Can be an object with drawing data or commands like "clear", "undo"
type: "pcs",
UUID: targetUUID // Optional: specific target
}, "*");
}
// Clear VDO.Ninja's drawing
function clearVDONinjaDrawing() {
iframe.contentWindow.postMessage({
draw: "clear",
type: "pcs"
}, "*");
}
// Undo last drawing action in VDO.Ninja
function undoVDONinjaDrawing() {
iframe.contentWindow.postMessage({
draw: "undo",
type: "pcs"
}, "*");
}
```
## Complete Example: Drawing Application
Here's a more complete example of a drawing application using the data channel:
```javascript
// Create interface elements
const container = document.createElement('div');
container.id = 'app-container';
document.body.appendChild(container);
// Create VDO.Ninja iframe
const iframe = document.createElement('iframe');
iframe.allow = "camera;microphone;fullscreen;display-capture;autoplay;";
iframe.src = "https://vdo.ninja/?room=drawing-demo&cleanoutput";
iframe.style.width = "640px";
iframe.style.height = "360px";
container.appendChild(iframe);
// Create drawing canvas
const canvasContainer = document.createElement('div');
canvasContainer.style.position = 'relative';
container.appendChild(canvasContainer);
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 360;
canvas.style.border = '1px solid black';
canvasContainer.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.strokeStyle = 'red';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
// Create controls
const controlsDiv = document.createElement('div');
controlsDiv.style.margin = '10px 0';
container.appendChild(controlsDiv);
const clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear';
clearBtn.onclick = clearDrawing;
controlsDiv.appendChild(clearBtn);
const undoBtn = document.createElement('button');
undoBtn.textContent = 'Undo';
undoBtn.onclick = undoLastDrawing;
controlsDiv.appendChild(undoBtn);
// Track connected peers
const connectedPeers = {};
const drawingHistory = [];
let currentPath = [];
let isDrawing = false;
// Set up event handlers for the canvas
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', endDrawing);
canvas.addEventListener('mouseout', endDrawing);
function startDrawing(e) {
isDrawing = true;
const x = e.offsetX / canvas.width;
const y = e.offsetY / canvas.height;
currentPath = [{ x, y }];
ctx.beginPath();
ctx.moveTo(e.offsetX, e.offsetY);
}
function draw(e) {
if (!isDrawing) return;
const x = e.offsetX / canvas.width;
const y = e.offsetY / canvas.height;
currentPath.push({ x, y });
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
function endDrawing() {
if (!isDrawing) return;
isDrawing = false;
if (currentPath.length > 1) {
// Save path to history
drawingHistory.push(currentPath);
// Send path to peers
sendDrawingData(currentPath);
}
currentPath = [];
}
function clearDrawing() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawingHistory.length = 0;
// Send clear command
iframe.contentWindow.postMessage({
sendData: { overlayNinja: { drawingData: "clear" } },
type: "pcs"
}, "*");
}
function undoLastDrawing() {
if (drawingHistory.length === 0) return;
// Remove the last path
drawingHistory.pop();
// Redraw everything
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawingHistory.forEach(path => {
if (path.length > 1) {
ctx.beginPath();
ctx.moveTo(path[0].x * canvas.width, path[0].y * canvas.height);
for (let i = 1; i < path.length; i++) {
ctx.lineTo(path[i].x * canvas.width, path[i].y * canvas.height);
}
ctx.stroke();
}
});
// Send undo command
iframe.contentWindow.postMessage({
sendData: { overlayNinja: { drawingData: "undo" } },
type: "pcs"
}, "*");
}
function sendDrawingData(pathPoints) {
const drawingData = {
t: 'path',
p: pathPoints,
c: 'red', // Color
w: 3 // Width
};
iframe.contentWindow.postMessage({
sendData: { overlayNinja: { drawingData: drawingData } },
type: "pcs"
}, "*");
}
// Set up the event listener
const eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
const eventer = window[eventMethod];
const messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
eventer(messageEvent, function(e) {
// Make sure the message is from our VDO.Ninja iframe
if (e.source != iframe.contentWindow) return;
// Process connection events
if ("action" in e.data) {
if (e.data.action === "guest-connected" && e.data.streamID) {
connectedPeers[e.data.streamID] = e.data.value?.label || "Guest";
console.log("Guest connected:", e.data.streamID, "Label:", connectedPeers[e.data.streamID]);
// Send current drawing state to new peer
if (drawingHistory.length > 0) {
iframe.contentWindow.postMessage({
sendData: { overlayNinja: { drawingHistory: drawingHistory } },
type: "pcs",
UUID: e.data.streamID
}, "*");
}
}
else if (e.data.action === "push-connection" && e.data.value === false && e.data.streamID) {
console.log("Guest disconnected:", e.data.streamID);
delete connectedPeers[e.data.streamID];
}
}
// Handle received data
if ("dataReceived" in e.data) {
if ("overlayNinja" in e.data.dataReceived) {
const data = e.data.dataReceived.overlayNinja;
// Process drawing data
if (data.drawingData) {
if (data.drawingData === "clear") {
// Clear command
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawingHistory.length = 0;
}
else if (data.drawingData === "undo") {
// Undo command
if (drawingHistory.length > 0) {
drawingHistory.pop();
// Redraw everything
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawingHistory.forEach(path => {
if (path.length > 1) {
ctx.beginPath();
ctx.moveTo(path[0].x * canvas.width, path[0].y * canvas.height);
for (let i = 1; i < path.length; i++) {
ctx.lineTo(path[i].x * canvas.width, path[i].y * canvas.height);
}
ctx.stroke();
}
});
}
}
else if (data.drawingData.t === 'path') {
// New path
const pathPoints = data.drawingData.p;
// Add to history
drawingHistory.push(pathPoints);
// Draw it
if (pathPoints && pathPoints.length > 1) {
ctx.beginPath();
ctx.moveTo(pathPoints[0].x * canvas.width, pathPoints[0].y * canvas.height);
for (let i = 1; i < pathPoints.length; i++) {
ctx.lineTo(pathPoints[i].x * canvas.width, pathPoints[i].y * canvas.height);
}
ctx.stroke();
}
}
}
// Handle initial state sync
if (data.drawingHistory) {
// Clear current state
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply all paths from history
data.drawingHistory.forEach(path => {
if (path.length > 1) {
ctx.beginPath();
ctx.moveTo(path[0].x * canvas.width, path[0].y * canvas.height);
for (let i = 1; i < path.length; i++) {
ctx.lineTo(path[i].x * canvas.width, path[i].y * canvas.height);
}
ctx.stroke();
}
});
// Update local history
drawingHistory.length = 0;
drawingHistory.push(...data.drawingHistory);
}
}
}
}, false);
```
## Best Practices
1. **Data Structure**: Use a clear, consistent data structure for your payloads
2. **Normalization**: Normalize canvas coordinates (0-1 range) to ensure consistent display across different screen sizes
3. **Throttling**: Consider throttling frequent events like mouse movements to reduce data transmission
4. **Error Handling**: Always include try/catch blocks when sending or processing data
5. **State Synchronization**: When new peers join, send them the current state
6. **UUID vs StreamID**: Use UUID for reliable targeting; StreamIDs change when connections restart
7. **Connection Status**: Monitor connection and disconnection events to maintain a list of active peers
## Common Types of Data to Send
- **Drawing Paths**: Arrays of points representing drawing strokes
- **Commands**: Clear, undo, change color, change brush size
- **Annotations**: Text or shapes to overlay on videos
- **Control Signals**: Camera directions, audio levels, recording commands
- **Chat Messages**: Text messages between users
- **Sensor Data**: Device orientation, location, acceleration
## Troubleshooting
- **Data Not Arriving**: Check that you're using the correct UUID or streamID
- **Timing Issues**: Ensure your iframe is fully loaded before sending messages
- **Cross-Origin Issues**: Make sure your security settings allow communication
- **Format Errors**: Verify your data structure matches what receivers expect
- **Performance Problems**: Large data payloads can cause lag; consider optimizing
By following this guide, you should be able to implement custom drawing tools or any other data-sharing features using VDO.Ninja's P2P data channels.

136
examples/powerpoint.html Normal file
View File

@@ -0,0 +1,136 @@
<html>
<head><title>PowerPoint Remote Controller</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
color:white;
font-family: tahoma, arial;
}
a {
color:white
}
iframe {
width:100%;
height:100%;
border:0;
margin:0;
padding:0;
display:block;
}
input{
padding:10px;
width:80%;
font-size:1.2em;
z-index: 1000;
}
div{
border:0;
margin:0;
padding:0;
text-align: center;
}
button{
border:0;
margin:0;
padding:0;
width:49%;
height:100%;
}
</style>
</head>
<body>
<div id="container1" style="width:100%;height:89%;display:none;"></div>
<div id="container2" style="width:100%;height:10%;display:none;">
<button onclick="prevSlide()" style='background-color:red'>Previous Slide</button>
<button onclick="nextSlide()" style='background-color:green'>Next Slide</button>
</div>
<div>
<h2>PowerPoint Remote Control interface</h2>
<input placeholder="Enter a Room name" id="viewlink" type="text" onchange="loadIframes()" /><br>
<br>
This app is a custom remote client for VDO.Ninja's PowerPoint remote control feature.
<br><br>
For this to work, the remote VDO.Ninja peer will need <b> &midiin </b> added to their URL, a virtual MIDI loopback device installed, PowerPoint running as an application, and the AutoHotKey script <a href='https://github.com/steveseguin/powerpoint_remote'>found here</a> running, with the MIDI loopback device selected as a MIDI Input device.
</div>
<script>
var iframe;
function nextSlide(){
if (iframe){
iframe.contentWindow.postMessage({"sendRawMIDI":{data:[176, 110, 11]}}, '*');
/// OR AS OF V22.12 YOU CAN DO:
//iframe.contentWindow.postMessage({"nextSlide":true}, '*');
}
}
function prevSlide(){
if (iframe){
iframe.contentWindow.postMessage({"sendRawMIDI":{data:[176, 110, 10]}}, '*');
/// OR AS OF V22.12 YOU CAN DO:
//iframe.contentWindow.postMessage({"prevSlide":true}, '*');
}
}
function customCommand(){ // just an example of what you can do to make a custom action.
if (iframe){
iframe.contentWindow.postMessage({"sendRawMIDI":{data:[176, 110, 12]}}, '*'); // You'll need to have autohotkey be updated to respond to this though.
}
}
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
if (urlEdited !== window.location.search){
warnlog(window.location.search + " changed to " + urlEdited);
window.history.pushState({path: urlEdited.toString()}, '', urlEdited.toString());
}
var urlParams = new URLSearchParams(urlEdited);
var room = false;
if (urlParams.has("room") || urlParams.has("r")){
room = urlParams.get("room") || urlParams.get("r") || false;
}
if (room){
loadIframes(room);
}
function loadIframes(roomname=false){
if (!roomname){
roomname = document.getElementById("viewlink").value;
}
document.getElementById("viewlink").parentNode.parentNode.removeChild(document.getElementById("viewlink").parentNode);
document.getElementById("container1").style.display="inline-block";
document.getElementById("container2").style.display="inline-block";
var path = window.location.host+window.location.pathname.split("/").slice(0,-1).join("/");
var room1 = "https://"+path+"/../?room="+roomname+"&push="+roomname+"_controller&webcam&autostart&minipreview";
iframe = document.createElement("iframe");
iframe.allow = "document-domain;encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
iframe.src = room1;
document.getElementById("container1").appendChild(iframe);
}
</script>
</body>
</html>

163
examples/ptz.html Normal file
View File

@@ -0,0 +1,163 @@
<html>
<head><title>PTZ Remote Controller</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
color:white;
font-family: tahoma, arial;
}
a {
color:white
}
iframe {
width:100%;
height:100%;
border:0;
margin:0;
padding:0;
display:block;
}
input{
padding:10px;
width:80%;
font-size:1.2em;
z-index: 1000;
}
div{
border:0;
margin:0;
padding:0;
text-align: center;
}
button{
border:0;
margin-top:min(10px, 1vh);
padding:min(10px, 1vh);
cursor:pointer;
}
span {
margin: 0 10px;
}
</style>
</head>
<body>
<div id="container1" style="width:100%;height:89%;display:none;"></div>
<div id="container2" style="width:100%;height:10%;display:none;">
<span>
<button onclick="pan(-10)" style='background-color:red'>⬅️⬅️</button>
<button onclick="pan(-1)" style='background-color:red'>Pan LEFT ⬅️</button>
<button onclick="pan(1)" style='background-color:green'>Pan RIGHT ➡️</button>
<button onclick="pan(10)" style='background-color:green'>➡️➡️️</button>
</span>
<span>
<button onclick="tilt(-10)" style='background-color:red'>⬆️⬆️</button>
<button onclick="tilt(-1)" style='background-color:red'>Tilt UP ⬆️</button>
<button onclick="tilt(1)" style='background-color:green'>Tilt DOWN ⬇️</button>
<button onclick="tilt(10)" style='background-color:green'>⬇️⬇️</button>
</span>
<span>
<button onclick="zoom(-10)" style='background-color:red'></button>
<button onclick="zoom(-1)" style='background-color:red'>Zoom OUT </button>
<button onclick="zoom(1)" style='background-color:green'>Zoom IN </button>
<button onclick="zoom(10)" style='background-color:green'></button>
</span>
</div>
<div>
<h2>PTZ Remote Control interface</h2>
<input placeholder="Enter a view link. ie) https://vdo.ninja/?view=abc123" id="viewlink" type="text" onchange="loadIframes()" /><br>
<br>
This app is a custom remote client for VDO.Ninja's PTZ remote control feature.
<br>
<br>
notes: Make sure the remote sender adds <b>&ptz</b> and <b>&remote</b> to their URL, otherwise PTZ remote control will not be allowed.
</div>
<script>
var iframe;
function pan(delta){
if (iframe){
console.log("PAN "+delta);
iframe.contentWindow.postMessage({"sendRequest":{pan:delta}}, '*');
}
}
function tilt(delta){
if (iframe){
iframe.contentWindow.postMessage({"sendRequest":{tilt:delta}}, '*');
}
}
function zoom(delta){
if (iframe){
iframe.contentWindow.postMessage({"sendRequest":{zoom:delta}}, '*');
}
}
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
if (urlEdited !== window.location.search){
warnlog(window.location.search + " changed to " + urlEdited);
window.history.pushState({path: urlEdited.toString()}, '', urlEdited.toString());
}
var urlParams = new URLSearchParams(urlEdited);
if (urlParams.has("view") || urlParams.has("v")){
if (window.location.host){
var path = window.location.host+window.location.pathname.replace("/examples/","/").split("/").slice(0,-1).join("/");
} else {
var path = "vdo.ninja";
}
document.getElementById("viewlink").value = "https://"+path+"/";
loadIframes();
}
function loadIframes(){
var iframesrc = document.getElementById("viewlink").value;
document.getElementById("viewlink").parentNode.parentNode.removeChild(document.getElementById("viewlink").parentNode);
document.getElementById("container1").style.display="inline-block";
document.getElementById("container2").style.display="inline-block";
//
var params = window.location.search || "";
if (iframesrc.includes("?")){
params = params.slice(1);
iframesrc = iframesrc + "&" + params
} else {
iframesrc = iframesrc + params
}
console.log(iframesrc);
iframe = document.createElement("iframe");
iframe.allow = "encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;gyroscope;";
iframe.src = iframesrc;
document.getElementById("container1").appendChild(iframe);
}
</script>
</body>
</html>

View File

@@ -1,23 +1,77 @@
p2p is a sample of how to use vdo.ninja as a data transport tunneling service
# VDO.Ninja Examples
twitch is an example of how to have a twitch live chat side-by-side with VDO.NInja on the same screen
This directory contains various examples demonstrating different features and capabilities of VDO.Ninja. All examples are accessible through the index.html file.
dual is an example of how to have two VDO.Ninja windows (or any windows really) open on the same page; Picture-in-Picture style
## Categories
sensors is an example of how to transmit sensor and video data from a phone to a computer, drawing it to canvas: youtube video for this exists
### Core API Examples
- **api_example.html** - Basic iframe API usage demonstration
- **simple_iframe_api.html** - Simple iframe API implementation
- **iframetesting.html** - Testing iframe functionality
- **iframe.outbound-stats.html** - Get stats from VDO.Ninja using the iframe API
- **mute_guest_iframe.html** - Control guest muting via iframe API
- **iframe_example.html** - Additional iframe API example
midi demonstrates the MIDI API for VDO.Ninja
### UI & Layout Examples
- **draggable.html** - Drag multiple windows around to create custom layouts
- **dual.html** - Two VDO.Ninja windows (Picture-in-Picture style)
- **custom_overlay.html** - Custom overlay implementation
- **rotated.html** - Video rotation example
- **slidingzoom.html** - Sliding zoom effect
draggable demonstrates how to drag multiple windows around, if you wanted to create a custom layout of elements. (experimental)
### Room Management
- **waiting_room.html** - Virtual waiting room implementation
- **simplelink.html** - Simple link generation
- **changepass.html** - Create passwords and HASH values for rooms
- **transfer.html** - Room transfer functionality
chat.html is an example of a chat-only interface for VDO.NInja; maybe dockable into OBS even
### Control Examples
- **obsremote.html** - Remotely control OBS using VDO.Ninja
- **webcontrol.html** - Web-based control interface
- **powerpoint.html** - PowerPoint control integration
- **gamecontroller.html** - Game controller input handling
- **switchmics.html** - Switch between microphones
iframe.outbound-stats.html demostrates how to get stats from VDO.Ninja using the IFRAME API
### Hardware & Sensors
- **midi.html** - MIDI API demonstration
- **webhid.html** - Interface with USB devices (e.g., StreamDeck)
- **sensors.html** - Transmit sensor and video data from phone to computer
- **sensoroverlay.html** - Overlay sensor data on video
- **accelerometer.html** - Accelerometer data usage
changepass lets you create passwords and related HASH values for VDO.NInja rooms
### Platform Integration
- **twitch.html** - Twitch live chat side-by-side with VDO.Ninja
- **youtube.html** - YouTube integration example
- **kick.html** - Kick platform integration
- **wireless.html** - Wireless streaming setup
- **zoom.html** - Publish to VDO.Ninja for window-capturing into Zoom
webhid demonstrates how to interface with a USB device, like a streamdeck (mouse/keyboard not supported)
### Specialized Applications
- **teleprompter.html** - Teleprompter implementation
- **teleprompt.html** - Alternative teleprompter
- **labelonly.html** - Display labels only
- **custom_labels.html** - Custom label implementation
- **socal.html** - Social streaming example
zoom.html is a tool for letting you publish into VDO.Ninja, but then full-screen the window once setup, allowing for window-capturing into zoom.
### Communication & Data
- **p2p.html** - Data transport tunneling service example
- **chat.html** - Chat-only interface (dockable into OBS)
- **googleai.html** - Google AI integration
obs_remote is also hosted on github elsewhere, but it's an example of how to remotely control OBS using VDO.Ninja's tunneling abilities
### Utilities
- **testsdp.html** - SDP testing utility
- **status.html** - Status monitoring
- **ptz.html** - Pan-Tilt-Zoom camera control
## Usage
1. Open `index.html` in a web browser to see all examples organized by category
2. Click on any example to launch it
3. View the source code of each example to understand the implementation
## Notes
- Examples may require specific VDO.Ninja features or permissions
- Some examples work best when used with specific hardware or platforms
- Always check browser console for debugging information
- Many examples include inline documentation in their source code

View File

@@ -77,7 +77,7 @@ window.onbeforeunload = function() {
return "Dude, are you sure you want to leave? Think of the kittens!"; // prevents accidental page reloads.
}
var WID = "testOBSN";
var WID = "testVDON";
if (urlParams.has("api")){
WID = urlParams.get("api");
} else if (urlParams.has("osc")){
@@ -94,21 +94,39 @@ if (urlParams.has("api")){
var href = window.location.href;
var arr = href.split('?');
var newurl;
if (arr.length > 1 && arr[1] !== '') {
newurl = href + '&api=' + WID;
} else {
newurl = href + '?api=' + WID;
if (arr.length > 1 && arr[1] !== '') {
newurl = href + '&api=' + encodeURIComponent(WID);
} else {
newurl = href + '?api=' + encodeURIComponent(WID);
}
window.history.pushState({path: newurl.toString()}, '', newurl.toString());
}
var path = "vdo.ninja"; //window.location.host+window.location.pathname.split("/").slice(0,-1).join("/");
var header = document.getElementById("header");
header.innerHTML += "Your Ninja Link: <a href='https://"+path+"/?api="+WID+"' target='_blank'>https://"+path+"/?api="+WID+"</a><br /><br />";
header.innerHTML += "<small>You can append your own VDO.Ninja parameters to this link, treating it like a normal VDO.Ninja link.</small>";
header.innerHTML += "<br /><br /><small>Code and documentation hosted at <a href='https://github.com/steveseguin/Companion-Ninja'>https://github.com/steveseguin/Companion-Ninja</a></small> <svg width='32' height='32' viewBox='0 0 1024 1024' fill='none' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z' transform='scale(64)' fill='#1B1F23'/></svg>";
var path = "vdo.ninja"; //window.location.host+window.location.pathname.split("/").slice(0,-1).join("/");
var header = document.getElementById("header");
var linkWrapper = document.createElement("div");
var linkLabel = document.createElement("span");
linkLabel.textContent = "Your Ninja Link: ";
var shareLink = document.createElement("a");
var shareURL = "https://" + path + "/?api=" + encodeURIComponent(WID);
shareLink.href = shareURL;
shareLink.target = "_blank";
shareLink.rel = "noopener";
shareLink.textContent = shareURL;
linkWrapper.appendChild(linkLabel);
linkWrapper.appendChild(shareLink);
header.appendChild(linkWrapper);
var info = document.createElement("small");
info.textContent = "You can append your own VDO.Ninja parameters to this link, treating it like a normal VDO.Ninja link.";
header.appendChild(info);
header.appendChild(document.createElement("br"));
header.appendChild(document.createElement("br"));
var repoInfo = document.createElement("small");
repoInfo.innerHTML = "Code and documentation hosted at <a href='https://github.com/steveseguin/Companion-Ninja'>https://github.com/steveseguin/Companion-Ninja</a>";
header.appendChild(repoInfo);
header.insertAdjacentHTML("beforeend", " <svg width='32' height='32' viewBox='0 0 1024 1024' fill='none' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z' transform='scale(64)' fill='#1B1F23'/></svg>");
var socket = null;
var connecting = false;
@@ -474,4 +492,4 @@ loadGuestCommands(3);
loadGuestCommands(4);
</script>
</body>
</html>
</html>

94
examples/rip.html Normal file
View File

@@ -0,0 +1,94 @@
<html>
<head><title>RIP Canvas Relay - VDO.Ninja</title>
<style>
body{
padding:0;
margin:0;
background-color: #0000;
}
iframe {
border:0;
margin:0;
padding:0;
display:block;
width:100%;
height:100%;
}
#container {
border:0;
margin:0;
padding:0;
display:block;
width:50%;
height:50%;
position:absolute;
top:0;
left:0;
}
button{
border:0;
margin:0;
padding:0;
display:block;
position:absolute;
bottom:0;
right:0;
z-index: 10;
}
</style>
</head>
<body id="main">
<div id="container"></div>
<button onclick="dosomething();">SOMETHING</button>
<script>
var iframe = document.createElement("iframe");
function dosomething(){
var video = iframe.contentWindow.document.body.querySelector("video");
var mediastream = new MediaStream();
video.muted=true;
video.style.display = "none";
video.captureStream().getTracks().forEach(trk=>{
mediastream.addTrack(trk);
});
video.onended = function(){
mediastream = null;
}
video.load();
}
function loadIframe(url=false){
if (url){
var iframesrc = url;
} else {
var iframesrc = document.getElementById("viewlink").value;
}
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
if (iframesrc==""){
iframesrc="./";
}
iframe.src = iframesrc;
document.body.appendChild(iframe);
//////////// LISTEN FOR EVENTS
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
});
}
loadIframe("https://youtu.be/XOJMKdwpTZE");
</script>
</body>
</html>

135
examples/rotated.html Normal file
View File

@@ -0,0 +1,135 @@
<html>
<head><title>Rotated Scene</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
}
iframe {
width:100%;
height:470px;
border:0;
margin:0;
padding:0;
display:block;
width: 100vh;
height: 100vw;
transform: rotate(90deg);
transform-origin: 0 0;
left: 100vw;
position: relative;
top: 0;
}
</style>
</head>
<body>
<script>
function removeStorage(cname){
localStorage.removeItem(cname);
}
function clearStorage(){
localStorage.clear();
if (!session.cleanOutput){
warnUser("The local storage and saved settings have been cleared", 1000);
}
}
function setStorage(cname, cvalue, hours=9999){ // not actually a cookie
var now = new Date();
var item = {
value: cvalue,
expiry: now.getTime() + (hours * 60 * 60 * 1000),
};
try{
localStorage.setItem(cname, JSON.stringify(item));
}catch(e){errorlog(e);}
}
function getStorage(cname) {
try {
var itemStr = localStorage.getItem(cname);
} catch(e){
errorlog(e);
return;
}
if (!itemStr) {
return "";
}
var item = JSON.parse(itemStr);
var now = new Date();
if (now.getTime() > item.expiry) {
localStorage.removeItem(cname);
return "";
}
return item.value;
}
(function(w) {
w.URLSearchParams = w.URLSearchParams || function(searchString) {
var self = this;
searchString = searchString.replace("??", "?");
self.searchString = searchString;
self.get = function(name) {
var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(self.searchString);
if (results == null) {
return null;
} else {
return decodeURI(results[1]) || 0;
}
};
};
})(window);
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
if (urlEdited !== window.location.search){
window.history.pushState({path: urlEdited.toString()}, '', urlEdited.toString());
}
var urlParams = new URLSearchParams(urlEdited);
var rotate = parseInt(urlParams.get("rotate")) || "90";
var sdfasd = decodeURIComponent(urlParams.get("link") || "") || getStorage("savedRotateLink") || "";
var linktoload = sdfasd || prompt("What URL would you like to load? (rotated)");
setStorage("savedRotateLink", linktoload, 99999);
var iframe = document.createElement("iframe");
iframe.allow = "document-domain;encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
iframe.src = linktoload;
if (rotate=="180"){
iframe.style.transform = "rotate(180deg)";
iframe.style.width = "100vw";
iframe.style.height = "100vh";
iframe.style.transformOrigin = "0 0;";
iframe.style.position = "rotate(180deg)";
iframe.style.left = "100vw";
iframe.style.top = "100vh";
} else if (rotate=="270"){
iframe.style.transform = "rotate(270deg)";
iframe.style.left = "0";
iframe.style.top = "100vh";
}
document.body.appendChild(iframe);
</script>
</body>
</html>

3171
examples/sandbox.html Normal file

File diff suppressed because it is too large Load Diff

367
examples/sensoroverlay.html Normal file
View File

@@ -0,0 +1,367 @@
<html>
<head>
<title>Sensor Data Overlay - VDO.Ninja</title>
<meta charset="UTF-8">
<style>
body{
padding:0;
margin:0;
background-color: #0000;
}
iframe {
border:0;
margin:0;
padding:0;
display:block;
}
#vdoninja {
width:100%;
height:100%;
}
#container {
border:0;
margin:0;
padding:0;
display:block;
width:100%;
height:100%;
position:absolute;
top:0;
left:0;
}
#overlay{
border:0;
margin:0;
padding:0;
display:block;
text-align:right;
position:absolute;
top:100px;
right:0;
z-index: 10;
color: white;
font-size:300%;
}
#canvas{
border:0;
margin:0;
padding:0;
display:block;
width:20%;
text-align:right;
height:100px;
position:absolute;
top:0;
right:0;
z-index: 5;
}
</style>
</head>
<body id="main">
<div id="overlay"></div>
<canvas id="canvas"></canvas>
<div id="container"></div>
<div id="map-container" style="position: relative;">
<iframe
id="mapFrame"
src="your_map_url"
width="600"
height="450"
style="position: absolute; top: 0; left: 0; border: 0; width: 600px; height: 450px; display:none;">
</iframe>
<div style="position: absolute; top: 0; left: 0; width: 600px; height: 450px; z-index: 10; cursor: default;">
</div>
<span id="marker" style="position: absolute; top: 50px; left: 50%; transform: translate(-50%, calc(-50% - 15px)); font-size: 30px;display:none;">📍</span>
</div>
</div>
<script>
function getColor(value) {
var hue = (Math.abs(value*100+50)).toString(10);
return ["hsl(", hue, ",100%,50%)"].join("");
}
var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
var height = context.canvas.height;
var width = context.canvas.width;
canvas.history_accel = [];
canvas.history_speed = [];
var canvasNew = true
function plotData(speed, accel) {
canvas.history_accel.push(accel);
canvas.history_speed.push(speed);
canvas.history_accel = canvas.history_accel.slice(-1 * canvas.width);
canvas.history_speed = canvas.history_speed.slice(-1 * canvas.width);
var maxSpeed = Math.max(...canvas.history_speed);
var interval = 10;
var target = canvas.target || (interval*1.5);
if (target && (maxSpeed > target)){
canvas.target = maxSpeed*1.5; // set it higher than it needs to be, so it doens't jump around a lot
var yScale = height / canvas.target;
context.clearRect(0, 0, width, height);
var w = 1;
var x = width - w;
for (var i = 0; i<canvas.history_speed.length;i++){
var accel = canvas.history_accel[i];
var speed = canvas.history_speed[i];
var val = (10-accel)/10;
if (val>1){val=1;}
else if (val<0){val=0;}
var color = getColor(val);
var y = height - speed * yScale;
context.fillStyle = color;
context.fillRect(x, y, w, height);
context.fillStyle = "#DDD5";
context.fillRect(x, y-2, w, 4);
if (y-5>0){
context.fillStyle = "#FFF3";
context.fillRect(x, y+2, w, 1);
}
var imageData = context.getImageData(w, 0, x, height);
context.putImageData(imageData, 0, 0);
context.clearRect(x, 0, w, height);
}
for (var tt = interval; tt<canvas.target;tt+=interval){
var y = parseInt(height - tt * yScale);
context.fillStyle = "#0555";
context.fillRect(0, y, width, 1);
}
return;
}
var val = (10-accel)/10;
if (val>1){val=1;}
else if (val<0){val=0;}
var color = getColor(val);
var yScale = height / target;
var w = 1;
var x = width - w;
var y = height - speed * yScale;
context.fillStyle = color;
context.fillRect(x, y, w, height);
context.fillStyle = "#DDD5";
context.fillRect(x, y-2, w, 4);
if (y-5>0){
context.fillStyle = "#FFF3";
context.fillRect(x, y+2, w, 1);
}
context.fillStyle = "#0555";
if (canvasNew){
canvasNew = false;
for (var tt = interval; tt<target;tt+=interval){
var y = parseInt(height - tt * yScale);
context.fillRect(0, y, width, 1);
}
} else {
for (var tt = interval; tt<target;tt+=interval){
var y = parseInt(height - tt * yScale);
context.fillRect(x, y, w, 1);
}
}
var imageData = context.getImageData(w, 0, x, height);
context.putImageData(imageData, 0, 0);
context.clearRect(x, 0, w, height);
}
function loadIframe(url=false){
var iframe = document.createElement("iframe");
if (url){
var iframesrc = url;
} else {
var iframesrc = document.getElementById("viewlink").value;
}
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
if (iframesrc==""){
iframesrc="./";
}
iframe.src = iframesrc;
iframe.id = "vdoninja";
document.getElementById("container").appendChild(iframe);
var outputWindow = document.getElementById("overlay");
var sensors = {};
//////////// LISTEN FOR EVENTS
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
var mapBounds = null;
function calculateBBox(lat, lon) {
// Calculate bounding box (bbox) based on the new latitude and longitude
// This requires some geographic calculations to ensure the map is centered
// and zoomed appropriately around the new marker position.
var delta = 0.05; // Determines the zoom level. Smaller delta = higher zoom
mapBounds = {};
mapBounds.left = lon - delta;
mapBounds.bottom = lat - delta;
mapBounds.right = lon + delta;
mapBounds.top = lat + delta;
return `${mapBounds.left},${mapBounds.bottom},${mapBounds.right},${mapBounds.top},`;
}
function isMarkerOutsideMapBounds(markerLat, markerLon) {
if (!mapBounds){return true;}
return (
markerLon < mapBounds.left ||
markerLon > mapBounds.right ||
markerLat < mapBounds.bottom ||
markerLat > mapBounds.top
);
}
function convertLatLngToPixel(lat, lon, mapBounds, mapWidth, mapHeight) {
// Calculate the range of the map in both dimensions
var lonRange = mapBounds.right - mapBounds.left;
var latRange = mapBounds.top - mapBounds.bottom;
// Calculate the position of the latitude and longitude relative to the map bounds
var relativeX = (lon - mapBounds.left) / lonRange;
var relativeY = (mapBounds.top - lat) / latRange; // Invert latitude because screen coordinates go top to bottom
// Convert these relative positions into pixel values
var x = relativeX * mapWidth;
var y = relativeY * mapHeight;
return { x: x, y: y };
}
function updateMap(lat, lon) {
var iframemap = document.getElementById('mapFrame');
iframemap.style = "filter: invert(90%)";
var src = 'https://www.openstreetmap.org/export/embed.html?bbox=' + calculateBBox(lat, lon) + '&layer=mapnik';
iframemap.src = src;
iframemap.onload = function(){
iframemap.style.display = "block";
document.getElementById("marker").style.display = "block";
}
}
function updateMarkerPosition(lat, lon) {
if (isMarkerOutsideMapBounds(lat,lon)) {
updateMap(lat, lon); // Function to load a new map image
}
// Convert lat, lon to pixel coordinates
var mapWidth = 600; // Width of your map in pixels
var mapHeight = 450; // Height of your map in pixels
var pixelCoords = convertLatLngToPixel(lat, lon, mapBounds, mapWidth, mapHeight);
// Update marker position
var marker = document.getElementById("marker");
marker.style.left = pixelCoords.x + 'px';
marker.style.top = pixelCoords.y + 'px';
// Check if marker is outside the current map bounds
}
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
if ("sensors" in e.data){
//console.log(e.data.sensors);
var speed = 0;
var lat = null;
var lon = null;
if (e.data.sensors.pos){
speed = e.data.sensors.pos.speed;
lat = e.data.sensors.pos.lat || null;
lon = e.data.sensors.pos.lon || null;
// e.data.sensors.pos.alt
// e.data.sensors.pos.t
}
if (isNaN(speed)) {
speed = 0;
}
var accel = 0;
if (e.data.sensors.lin){
accel += Math.pow(e.data.sensors.lin.x, 2);
accel += Math.pow(e.data.sensors.lin.y, 2);
accel += Math.pow(e.data.sensors.lin.z, 2);
}
if (accel){
accel = Math.pow(accel,0.5);
}
if (isNaN(accel)){
accel = 0;
}
plotData(speed, accel);
outputWindow.innerHTML = "";
speed = parseInt(speed*100)/100;
outputWindow.innerHTML += "speed: "+speed+"m/s<br />";
accel = parseInt(accel*100)/100;
outputWindow.innerHTML += "acceleration: " + accel + "m/s^2<br />";
if ((lat!==null) && (lon!==null)){
updateMarkerPosition(lat, lon);
}
//for (var key in e.data.sensors.lin) {
// outputWindow.innerHTML += key + " lin: " + e.data.sensors.lin[key] + "<br />";
//}
//for (var key in e.data.sensors.acc) {
// outputWindow.innerHTML += key + " acc: " + e.data.sensors.acc[key] + "<br />";
//}
//for (var key in e.data.sensors.mag) {
// outputWindow.innerHTML += key + " magnet: " + e.data.sensors.mag[key] + "<br />";
//}
//for (var key in e.data.sensors.ori) {
// outputWindow.innerHTML += key + " orientation: " + e.data.sensors.ori[key] + "<br />";
//}
}
});
}
loadIframe("../"+window.location.search);
</script>
</body>
</html>

View File

@@ -99,8 +99,50 @@ function loadIframe(url=false){ // this is pretty important if you want to avoi
document.getElementById("container").appendChild(iframeContainer);
var videos = iframe.contentWindow.document.querySelectorAll("video");
var sensors = {};
var videos = iframe.contentWindow.document.querySelectorAll("video");
var sensors = {};
function appendTextLine(container, text, indentLevel){
var line = document.createElement("div");
if (indentLevel){
line.style.marginLeft = (indentLevel * 12) + "px";
}
line.textContent = text;
container.appendChild(line);
}
function appendKeyValueList(container, obj, indentLevel){
indentLevel = indentLevel || 0;
for (var key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
continue;
}
var value = obj[key];
if (typeof value === "object" && value !== null) {
appendTextLine(container, key + ":", indentLevel);
appendKeyValueList(container, value, indentLevel + 1);
} else {
appendTextLine(container, key + ": " + value, indentLevel);
}
}
}
function getOrCreateOutput(id, borderStyle){
var element = id ? document.getElementById(id) : null;
if (element){
element.textContent = "";
return element;
}
var div = document.createElement("div");
if (borderStyle){
div.style.border = borderStyle;
}
if (id){
div.id = id;
}
iframeContainer.appendChild(div);
return div;
}
function drawFrame(vid){
try {
@@ -159,122 +201,99 @@ function loadIframe(url=false){ // this is pretty important if you want to avoi
eventer(messageEvent, function (e) {
if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
if ("stats" in e.data){
var outputWindow = document.createElement("div");
//console.log(e.data.stats);
var out = "<br />total_inbound_connections:"+e.data.stats.total_inbound_connections;
out += "<br />total_outbound_connections:"+e.data.stats.total_outbound_connections;
for (var streamID in e.data.stats.inbound_stats){
out += "<br /><br /><b>streamID:</b> "+streamID+"<br />";
out += printValues(e.data.stats.inbound_stats[streamID]);
}
outputWindow.innerHTML = out;
iframeContainer.appendChild(outputWindow);
}
if ("gotChat" in e.data){
var outputWindow = document.createElement("div");
outputWindow.innerHTML = e.data.gotChat.msg;
outputWindow.style.border="1px dotted black";
iframeContainer.appendChild(outputWindow);
}
if ("action" in e.data){
var outputWindow = document.createElement("div");
outputWindow.innerHTML = "child-page-action: "+e.data.action+"<br />";
outputWindow.style.border="1px dotted black";
iframeContainer.appendChild(outputWindow);
console.log(e.data.action);
if (e.data.action == "new-view-connection"){
setTimeout(function(){
videos = iframe.contentWindow.document.querySelectorAll("video");
console.log(videos);
},500);
}
}
if ("streamIDs" in e.data){
var outputWindow = document.createElement("div");
outputWindow.innerHTML = "child-page-action: streamIDs<br />";
for (var key in e.data.streamIDs) {
outputWindow.innerHTML += "streamID: " + key + ", label:"+e.data.streamIDs[key] + "\n";
}
outputWindow.style.border="1px dotted black";
iframeContainer.appendChild(outputWindow);
}
if ("loudness" in e.data){
//console.log(e.data);
if (document.getElementById("loudness")){
outputWindow = document.getElementById("loudness");
} else {
var outputWindow = document.createElement("div");
outputWindow.style.border="1px dotted black";
iframeContainer.appendChild(outputWindow);
outputWindow.id = "loudness";
}
outputWindow.innerHTML = "child-page-action: loudness<br />";
for (var key in e.data.loudness) {
outputWindow.innerHTML += key + " Loudness: " + e.data.loudness[key] + "\n";
}
outputWindow.style.border="1px black";
}
if ("sensors" in e.data){
sensors = e.data.sensors;
if (document.getElementById("sensors")){
outputWindow = document.getElementById("sensors");
} else {
var outputWindow = document.createElement("div");
outputWindow.style.border="1px dotted black";
iframeContainer.appendChild(outputWindow);
outputWindow.id = "sensors";
console.log(sensors);
}
outputWindow.innerHTML = "child-page-action: sensors<br /><br />";
for (var key in e.data.sensors.lin) {
outputWindow.innerHTML += key + " linear: " + e.data.sensors.lin[key] + "<br />";
}
for (var key in e.data.sensors.acc) {
outputWindow.innerHTML += key + " acceleration: " + e.data.sensors.acc[key] + "<br />";
}
for (var key in e.data.sensors.gyro) {
outputWindow.innerHTML += key + " gyro: " + e.data.sensors.gyro[key] + "<br />";
}
for (var key in e.data.sensors.mag) {
outputWindow.innerHTML += key + " magnet: " + e.data.sensors.mag[key] + "<br />";
}
for (var key in e.data.sensors.ori) {
outputWindow.innerHTML += key + " orientation: " + e.data.sensors.ori[key] + "<br />";
}
outputWindow.style.border="1px black";
}
if ("stats" in e.data){
var statsBox = document.createElement("div");
appendTextLine(statsBox, "total_inbound_connections: " + e.data.stats.total_inbound_connections);
appendTextLine(statsBox, "total_outbound_connections: " + e.data.stats.total_outbound_connections);
for (var streamID in e.data.stats.inbound){
if (!Object.prototype.hasOwnProperty.call(e.data.stats.inbound, streamID)){
continue;
}
var streamHeader = document.createElement("div");
streamHeader.style.marginTop = "8px";
var streamLabel = document.createElement("strong");
streamLabel.textContent = "streamID:";
streamHeader.appendChild(streamLabel);
streamHeader.appendChild(document.createTextNode(" " + streamID));
statsBox.appendChild(streamHeader);
appendKeyValueList(statsBox, e.data.stats.inbound[streamID], 1);
}
iframeContainer.appendChild(statsBox);
}
if ("gotChat" in e.data){
var chatBox = document.createElement("div");
chatBox.textContent = e.data.gotChat.msg;
chatBox.style.border="1px dotted black";
iframeContainer.appendChild(chatBox);
}
if ("action" in e.data){
var actionBox = document.createElement("div");
appendTextLine(actionBox, "child-page-action: " + e.data.action);
actionBox.style.border="1px dotted black";
iframeContainer.appendChild(actionBox);
console.log(e.data.action);
if (e.data.action == "new-view-connection"){
setTimeout(function(){
videos = iframe.contentWindow.document.querySelectorAll("video");
console.log(videos);
},500);
}
}
if ("streamIDs" in e.data){
var streamBox = document.createElement("div");
streamBox.style.border="1px dotted black";
appendTextLine(streamBox, "child-page-action: streamIDs");
for (var key in e.data.streamIDs) {
if (!Object.prototype.hasOwnProperty.call(e.data.streamIDs, key)){
continue;
}
appendTextLine(streamBox, "streamID: " + key + ", label: " + e.data.streamIDs[key], 1);
}
iframeContainer.appendChild(streamBox);
}
if ("loudness" in e.data){
var loudnessBox = getOrCreateOutput("loudness", "1px dotted black");
appendTextLine(loudnessBox, "child-page-action: loudness");
for (var key in e.data.loudness) {
if (!Object.prototype.hasOwnProperty.call(e.data.loudness, key)){
continue;
}
appendTextLine(loudnessBox, key + " Loudness: " + e.data.loudness[key], 1);
}
loudnessBox.style.border="1px black";
}
if ("sensors" in e.data){
sensors = e.data.sensors;
var sensorsBox = getOrCreateOutput("sensors", "1px dotted black");
appendTextLine(sensorsBox, "child-page-action: sensors");
for (var sensorKey in e.data.sensors) {
if (!Object.prototype.hasOwnProperty.call(e.data.sensors, sensorKey)){
continue;
}
var sensorValue = e.data.sensors[sensorKey];
if (typeof sensorValue === "object" && sensorValue !== null){
appendTextLine(sensorsBox, sensorKey + ":", 0);
appendKeyValueList(sensorsBox, sensorValue, 1);
} else {
appendTextLine(sensorsBox, sensorKey + ": " + sensorValue, 0);
}
}
sensorsBox.style.border="1px black";
}
});
}
function printValues( obj) {
var out = "";
for (var key in obj) {
if (typeof obj[key] === "object") {
out +="<br />";
out += printValues(obj[key]);
} else {
out +="<b>"+key+"</b>: "+obj[key]+"<br />";
}
}
return out;
}
</script>
</body>
</html>
</script>
</body>
</html>

View File

@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html>
<head>
<title>Simple IFRAME Replacement Example</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.example {
border: 2px solid #ddd;
padding: 20px;
margin: 20px 0;
border-radius: 8px;
}
.old-way {
background: #fff3e0;
}
.new-way {
background: #e8f5e9;
}
pre {
background: #f5f5f5;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
}
button {
padding: 10px 20px;
font-size: 16px;
margin: 5px;
cursor: pointer;
}
.messages {
border: 1px solid #ddd;
padding: 10px;
height: 200px;
overflow-y: auto;
margin: 10px 0;
background: white;
}
h2 {
margin-top: 0;
}
</style>
</head>
<body>
<h1>Simple IFRAME Replacement with VDO.Ninja DataChannel SDK</h1>
<div class="example old-way">
<h2>❌ Old Way: Hidden IFRAME</h2>
<pre><code>// HTML
&lt;iframe id="vdo-iframe"
src="https://vdo.ninja/?room=myroom&amp;push=sender&amp;view=receiver&amp;datachannel=true"
style="display:none"&gt;&lt;/iframe&gt;
// JavaScript
const iframe = document.getElementById('vdo-iframe');
// Send message
iframe.contentWindow.postMessage({
action: 'send-data',
data: 'Hello from parent'
}, '*');
// Receive messages
window.addEventListener('message', (event) => {
if (event.origin !== 'https://vdo.ninja') return;
console.log('Received:', event.data);
});</code></pre>
<p><strong>Problems:</strong></p>
<ul>
<li>Hidden IFRAME still loads full VDO.Ninja UI</li>
<li>Cross-origin communication limitations</li>
<li>No direct control over connections</li>
<li>Hard to debug</li>
</ul>
</div>
<div class="example new-way">
<h2>✅ New Way: DataChannel SDK</h2>
<pre><code>// JavaScript only - no hidden IFRAME needed!
const node = new VDONinjaDataChannel();
// Connect and join room
await node.joinRoom({
room: 'myroom',
streamID: 'sender',
label: 'my-device'
});
// Send message
node.publish({ data: 'Hello from SDK' });
// Receive messages
node.addEventListener('data', (event) => {
console.log('Received:', event.detail.data);
});</code></pre>
<p><strong>Benefits:</strong></p>
<ul>
<li>No hidden IFRAME overhead</li>
<li>Direct DataChannel control</li>
<li>Label-based routing</li>
<li>Mesh networking support</li>
<li>Better debugging</li>
</ul>
</div>
<div class="example">
<h2>🚀 Live Demo</h2>
<p>Try it out! This demo shows two nodes communicating without any IFRAMEs.</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div>
<h3>Node A</h3>
<button onclick="connectNodeA()">Connect</button>
<button onclick="sendFromA()">Send Message</button>
<div class="messages" id="messagesA"></div>
</div>
<div>
<h3>Node B</h3>
<button onclick="connectNodeB()">Connect</button>
<button onclick="sendFromB()">Send Message</button>
<div class="messages" id="messagesB"></div>
</div>
</div>
</div>
<div class="example">
<h2>📝 Common Use Cases</h2>
<h3>1. IoT Sensor Network</h3>
<pre><code>// Sensor publishes data
const sensor = new VDONinjaDataChannel();
await sensor.joinRoom({ room: 'iot', label: 'temperature-sensor' });
setInterval(() => {
sensor.publish({
temp: Math.random() * 30 + 20,
unit: 'celsius',
timestamp: Date.now()
});
}, 5000);</code></pre>
<h3>2. Remote Control</h3>
<pre><code>// Controller subscribes to specific device
const controller = new VDONinjaDataChannel();
await controller.joinRoom({ room: 'iot', label: 'controller' });
// Subscribe to all temperature sensors
controller.subscribe('temperature-sensor');
controller.addEventListener('data', (e) => {
if (e.detail.data.temp > 25) {
// Send command back
controller.publish(
{ command: 'turn-on-fan' },
{ toLabel: e.detail.label }
);
}
});</code></pre>
<h3>3. Multi-User Chat</h3>
<pre><code>// Each user joins with unique ID but same label
const chat = new VDONinjaDataChannel();
await chat.joinRoom({
room: 'chatroom',
streamID: `user-${Date.now()}`,
label: 'chat-user'
});
// Subscribe to all chat users
chat.subscribe('chat-user');
// Send message to all
chat.publish({
message: 'Hello everyone!',
user: 'John'
});</code></pre>
</div>
<script src="../vdoninja-datachannel-sdk.js"></script>
<script>
let nodeA = null;
let nodeB = null;
let messageCount = { A: 0, B: 0 };
async function connectNodeA() {
if (nodeA) nodeA.disconnect();
nodeA = new VDONinjaDataChannel({
wss: 'wss://apibackup.vdo.ninja:443'
});
nodeA.addEventListener('data', (e) => {
addMessage('messagesA', `Received: ${JSON.stringify(e.detail.data)}`);
});
nodeA.addEventListener('peer-connected', (e) => {
addMessage('messagesA', '🔗 Peer connected!');
});
try {
await nodeA.joinRoom({
room: `demo-${window.location.hostname}`,
streamID: 'node-a',
label: 'demo-node'
});
addMessage('messagesA', '✅ Connected to room');
// Auto-discover and connect to peers
setTimeout(async () => {
const peers = Array.from(nodeA.peerStreamIDs.keys());
for (const peer of peers) {
if (peer !== nodeA.uuid) {
await nodeA.view(nodeA.peerStreamIDs.get(peer));
}
}
}, 1000);
} catch (error) {
addMessage('messagesA', `❌ Error: ${error.message}`);
}
}
async function connectNodeB() {
if (nodeB) nodeB.disconnect();
nodeB = new VDONinjaDataChannel({
wss: 'wss://apibackup.vdo.ninja:443'
});
nodeB.addEventListener('data', (e) => {
addMessage('messagesB', `Received: ${JSON.stringify(e.detail.data)}`);
});
nodeB.addEventListener('peer-connected', (e) => {
addMessage('messagesB', '🔗 Peer connected!');
});
try {
await nodeB.joinRoom({
room: `demo-${window.location.hostname}`,
streamID: 'node-b',
label: 'demo-node'
});
addMessage('messagesB', '✅ Connected to room');
// Subscribe to demo nodes
nodeB.subscribe('demo-node');
// Auto-connect to node A
setTimeout(() => {
nodeB.view('node-a');
}, 1000);
} catch (error) {
addMessage('messagesB', `❌ Error: ${error.message}`);
}
}
function sendFromA() {
if (!nodeA || !nodeA.connected) {
alert('Node A not connected!');
return;
}
messageCount.A++;
const data = {
message: `Hello from Node A (#${messageCount.A})`,
timestamp: new Date().toISOString()
};
nodeA.publish(data);
addMessage('messagesA', `Sent: ${JSON.stringify(data)}`);
}
function sendFromB() {
if (!nodeB || !nodeB.connected) {
alert('Node B not connected!');
return;
}
messageCount.B++;
const data = {
message: `Hello from Node B (#${messageCount.B})`,
timestamp: new Date().toISOString()
};
nodeB.publish(data);
addMessage('messagesB', `Sent: ${JSON.stringify(data)}`);
}
function addMessage(containerId, message) {
const container = document.getElementById(containerId);
const div = document.createElement('div');
div.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (nodeA) nodeA.disconnect();
if (nodeB) nodeB.disconnect();
});
</script>
</body>
</html>

437
examples/simplelink.html Normal file
View File

@@ -0,0 +1,437 @@
<html>
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<meta content="utf-8" http-equiv="encoding" />
<meta name="copyright" content="&copy; 2025 Steve Seguin" />
<meta name="license" content="https://github.com/steveseguin/vdo.ninja/LICENSE.md" />
<meta name="sourcecode" content="https://github.com/steveseguin/vdo.ninja" />
<meta name="stance-on-war" content="Steve Seguin condemns Russia's brutal invasion of Ukraine 💙💛." />
<meta name="robots" content="index, follow">
<link rel="author" href="/about" />
<link rel="me" href="https://vdo.ninja/about" />
<!-- Primary Meta Tags -->
<title>Simple Link Generator</title>
<meta id="metaTitle" name="title" content="VDO.Ninja" />
<meta name="description" content="Bring live video from your smartphone, computer, or friends directly into your Studio. 100% free." />
<meta name="author" content="Steve Seguin" />
<style>
/* Previous styles remain the same */
:root {
--bg-color: #1a1a1a;
--card-bg: #2d2d2d;
--text-color: #e0e0e0;
--border-color: #404040;
--highlight-color: #3d7eaa;
--input-bg: #363636;
--hover-bg: #383838;
}
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 15px;
background: var(--bg-color);
color: var(--text-color);
line-height: 1.5;
max-width: 1200px;
margin: 0 auto;
}
.main-title {
color: var(--text-color);
text-align: center;
margin: 20px 0;
font-size: 2rem;
font-weight: 600;
}
.card {
background: var(--card-bg);
padding: 15px;
margin: 15px 0;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
}
h2 {
margin-top: 0;
font-size: 1.3rem;
color: var(--text-color);
}
.device {
cursor: pointer;
padding: 12px;
margin: 8px 0;
border: 1px solid var(--border-color);
border-radius: 8px;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
gap: 4px;
}
.device:hover {
background: var(--hover-bg);
}
.device.selected {
background: var(--highlight-color);
border-color: var(--highlight-color);
}
.device-name {
font-weight: 500;
}
.device-id {
color: #888;
font-size: 0.85em;
word-break: break-all;
}
.options {
display: grid;
gap: 15px;
}
input[type="text"] {
width: 100%;
padding: 12px;
margin: 5px 0;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-color);
font-size: 1rem;
}
input[type="text"]:focus {
outline: none;
border-color: var(--highlight-color);
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0;
font-size: 1rem;
}
input[type="checkbox"], input[type="radio"] {
width: 18px;
height: 18px;
accent-color: var(--highlight-color);
}
button {
background: var(--highlight-color);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
width: 100%;
max-width: 300px;
transition: all 0.2s ease;
}
button:hover {
filter: brightness(1.1);
}
#generatedUrl {
width: 100%;
padding: 12px;
margin: 10px 0;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-color);
font-family: monospace;
font-size: 0.9rem;
word-break: break-all;
cursor: pointer;
transition: all 0.3s ease;
}
#broadcastOptions {
display: none;
border-top: 1px solid var(--border-color);
margin-top: 15px;
padding-top: 15px;
}
#broadcastOptions.visible {
display: block;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 10px;
margin: 10px 0;
}
.radio-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 6px;
transition: background 0.2s;
}
.radio-row:hover {
background: var(--hover-bg);
}
@media (max-width: 600px) {
body {
padding: 10px;
}
.card {
padding: 12px;
margin: 10px 0;
}
.device {
padding: 10px;
}
button {
width: 100%;
max-width: none;
}
}
@media (min-width: 768px) {
.options {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
}
.hidden{
display:none;
}
#generatedUrl:hover {
background: var(--hover-bg);
}
#generatedUrl.copied {
background: var(--highlight-color);
border-color: var(--highlight-color);
}
</style>
</head>
<body>
<h1 class="main-title">Simple Link Generator</h1>
<div class="card">
<h2>🎥 Select Video Input Device</h2>
<div id="videoInputs"></div>
</div>
<div class="card">
<h2>🎤 Select Audio Input Device</h2>
<div id="audioInputs"></div>
</div>
<div class="card">
<h2>⚙️ Options</h2>
<div class="options">
<div>
<input type="text" id="roomName" placeholder="Room Name (optional)" oninput="toggleBroadcastOptions()" />
<input type="text" id="password" placeholder="Password (optional)" />
<input type="text" id="streamId" placeholder="Stream ID (optional)" />
<div class="checkbox-row">
<input type="checkbox" id="autostart" />
<label for="autostart">Auto-start camera/mic</label>
</div>
<div class="checkbox-row hidden">
<input type="checkbox" id="webcam" checked />
<label for="webcam">Enable webcam mode</label>
</div>
<div id="broadcastOptions">
<h3>Room View Options</h3>
<div class="radio-group">
<div class="radio-row">
<input type="radio" id="normal" name="broadcastMode" value="normal" checked />
<label for="normal">See and hear all (Default)</label>
</div>
<div class="radio-row">
<input type="radio" id="broadcast" name="broadcastMode" value="bc" />
<label for="broadcast">See director, hear all</label>
</div>
<div class="radio-row">
<input type="radio" id="directoronly" name="broadcastMode" value="do" />
<label for="directoronly">Only see/hear director</label>
</div>
<div class="radio-row">
<input type="radio" id="view" name="broadcastMode" value="v" />
<label for="view">See/hear no one</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<h2>🔗 Generated URL</h2>
<button onclick="generateUrl()">Generate URL</button>
<input type="text" id="generatedUrl" readonly placeholder="Generated link will appear here..." />
</div>
<script>
const baseUrl = new URL(document.location.origin);
let selectedVideo = null;
let selectedAudioDevices = [];
function toggleBroadcastOptions() {
const roomName = document.getElementById('roomName').value.trim();
const broadcastOptions = document.getElementById('broadcastOptions');
broadcastOptions.classList.toggle('visible', roomName.length > 0);
}
function sanitizeDeviceName(deviceName) {
return String(deviceName).toLowerCase().replace(/[\W]+/g, "_");
}
function addDevice(element) {
const type = element.dataset.deviceType;
const device = sanitizeDeviceName(element.querySelector('.device-name').innerText);
if (type === 'audioinput') {
handleAudioSelection(element, device);
} else if (type === 'videoinput') {
handleVideoSelection(element, device);
}
}
function handleAudioSelection(element, device) {
if (element.classList.contains('selected')) {
const index = selectedAudioDevices.indexOf(device);
if (index !== -1) {
selectedAudioDevices.splice(index, 1);
}
element.classList.remove('selected');
} else {
selectedAudioDevices.push(device);
element.classList.add('selected');
}
}
function handleVideoSelection(element, device) {
const prevSelected = element.parentElement.querySelector('.selected');
if (prevSelected) {
prevSelected.classList.remove('selected');
}
if (element === prevSelected) {
selectedVideo = null;
} else {
selectedVideo = device;
element.classList.add('selected');
}
}
function generateUrl() {
const url = new URL(baseUrl);
if (selectedVideo === null) {
url.searchParams.set('vd', '0');
} else if (selectedVideo) {
url.searchParams.set('vd', selectedVideo);
}
if (selectedAudioDevices.length === 0) {
url.searchParams.set('ad', '0');
} else {
url.searchParams.set('ad', selectedAudioDevices.join(','));
}
const roomName = document.getElementById('roomName').value.trim();
const password = document.getElementById('password').value.trim();
const streamId = document.getElementById('streamId').value.trim();
const autostart = document.getElementById('autostart').checked;
const webcam = document.getElementById('webcam').checked;
const broadcastMode = document.querySelector('input[name="broadcastMode"]:checked').value;
if (roomName) {
url.searchParams.set('r', roomName);
if (broadcastMode !== 'normal') {
url.searchParams.set(broadcastMode, '');
}
}
if (password) url.searchParams.set('pw', password);
if (streamId) url.searchParams.set('id', streamId);
if (autostart) url.searchParams.set('as', '');
if (webcam) url.searchParams.set('wc', '');
url.searchParams.set('hh', '');
const urlField = document.getElementById('generatedUrl')
urlField.value = decodeURIComponent(url);
urlField.classList.add('copied');
navigator.clipboard.writeText(urlField.value);
setTimeout(() => urlField.classList.remove('copied'), 1000);
}
function prettyPrint(devices, elementId) {
let output = "<div class='device-list'>";
devices.forEach(device => {
output += `
<div class='device' onclick='addDevice(this)' data-device-type='${device.kind}'>
<span class='device-name'>${device.label}</span>
<span class='device-id'>${device.deviceId}</span>
</div>`;
});
output += "</div>";
document.getElementById(elementId).innerHTML = output;
}
async function requestPermissions() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
stream.getTracks().forEach(track => track.stop());
} catch (e) {
console.warn("Permission request failed:", e);
}
}
document.getElementById('generatedUrl').onclick = function() {
if (this.value) {
navigator.clipboard.writeText(this.value).then(() => {
this.classList.add('copied');
setTimeout(() => this.classList.remove('copied'), 1000);
});
}
};
async function initializeDevices() {
await requestPermissions();
try {
const devices = await navigator.mediaDevices.enumerateDevices();
prettyPrint(devices.filter(d => d.kind === 'videoinput'), 'videoInputs');
prettyPrint(devices.filter(d => d.kind === 'audioinput'), 'audioInputs');
} catch (err) {
console.error(err);
}
}
initializeDevices();
</script>
</body>
</html>

176
examples/slidingzoom.html Normal file
View File

@@ -0,0 +1,176 @@
<html>
<head><title>PTZ Remote Controller</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<meta content="utf-8" http-equiv="encoding" />
<meta name="copyright" content="&copy; 2025 Steve Seguin" />
<meta name="license" content="https://github.com/steveseguin/vdo.ninja/LICENSE.md" />
<meta name="sourcecode" content="https://github.com/steveseguin/vdo.ninja" />
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://vdo.ninja/">
<link rel="author" href="/about" />
<link rel="me" href="https://vdo.ninja/about" />
<meta id="metaTitle" name="title" content="VDO.Ninja" />
<meta name="description" content="Bring live video from your smartphone, computer, or friends directly into your Studio. 100% free." />
<meta name="author" content="Steve Seguin" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#0f131d" />
<style>
body {
padding: 0;
margin: 0;
background-color: #000;
width: 100%;
height: 100%;
color: white;
font-family: tahoma, arial;
}
a { color: white }
iframe {
width: 100%;
height: 100%;
border: 0;
margin: 0;
padding: 0;
display: block;
}
input {
padding: 10px;
width: 80%;
font-size: 1.2em;
z-index: 1000;
}
div {
border: 0;
margin: 0;
padding: 0;
text-align: center;
}
.slider-container {
width: 80%;
margin: 20px auto;
padding: 10px;
text-align: center;
}
.zoom-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 40px;
background: #444;
outline: none;
border-radius: 20px;
}
.zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 60px;
height: 60px;
background: #4CAF50;
cursor: pointer;
border-radius: 30px;
border: none;
}
.zoom-slider::-moz-range-thumb {
width: 60px;
height: 60px;
background: #4CAF50;
cursor: pointer;
border-radius: 30px;
border: none;
}
</style>
</head>
<body>
<div id="container1" style="width:100%;height:89%;display:none;"></div>
<div id="container2" style="width:100%;height:10%;display:none;">
<div class="slider-container">
<input type="range" min="-100" max="100" value="0" class="zoom-slider" id="zoomSlider">
</div>
</div>
<div>
<h2>Remote Zoom Control interface</h2>
<input placeholder="Enter a view link. ie) https://vdo.ninja/?view=abc123" id="viewlink" type="text" onchange="loadIframes()" /><br>
<br>
This app is a custom remote client for VDO.Ninja's remote zoom control feature. It uses a slider for mobile friendly use.
<br>
<br>
notes: Make sure the remote sender adds <b>&ptz</b> and <b>&remote</b> to their URL, otherwise PTZ remote control will not be allowed.
</div>
<script>
var iframe;
var lastZoomValue = 0;
function handleZoom(value) {
if (iframe) {
const delta = value - lastZoomValue;
if (delta !== 0) {
iframe.contentWindow.postMessage({"sendRequest":{zoom:delta}}, '*');
lastZoomValue = value;
}
}
}
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
if (urlEdited !== window.location.search) {
console.warn(window.location.search + " changed to " + urlEdited);
window.history.pushState({path: urlEdited.toString()}, '', urlEdited.toString());
}
var urlParams = new URLSearchParams(urlEdited);
if (urlParams.has("view") || urlParams.has("v")) {
if (window.location.host) {
var path = window.location.host+window.location.pathname.replace("/examples/","/").split("/").slice(0,-1).join("/");
} else {
var path = "vdo.ninja";
}
document.getElementById("viewlink").value = "https://"+path+"/";
loadIframes();
}
function loadIframes() {
var iframesrc = document.getElementById("viewlink").value;
document.getElementById("viewlink").parentNode.parentNode.removeChild(document.getElementById("viewlink").parentNode);
document.getElementById("container1").style.display="inline-block";
document.getElementById("container2").style.display="inline-block";
var params = window.location.search || "";
if (iframesrc.includes("?")) {
params = params.slice(1);
iframesrc = iframesrc + "&" + params
} else {
iframesrc = iframesrc + params
}
console.log(iframesrc);
iframe = document.createElement("iframe");
iframe.allow = "encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;gyroscope;";
iframe.src = iframesrc;
document.getElementById("container1").appendChild(iframe);
// Setup zoom slider events after iframe is loaded
document.getElementById('zoomSlider').addEventListener('input', function(e) {
handleZoom(parseInt(e.target.value));
});
}
</script>
</body>
</html>

185
examples/socal.html Normal file
View File

@@ -0,0 +1,185 @@
<html>
<head><title>SocialStream + Video</title>
<meta name="viewport" content="width=device-width, initial-scale=0.7, maximum-scale=1.0, user-scalable=yes" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
}
iframe {
width:100%;
height:100%;
border:0;
margin:0;
padding:0;
position:absolute;
display:block;
}
input{
padding:10px;
width:80%;
font-size:1.2em;
z-index: 1000;
}
h1{
color: white;
font-family: verdana;
margin: 10px;
}
#container2{
width:100vw;
height:100vh;
position: fixed;
top: 0;
left:0;
display:none;
z-index:2;
}
#container1{
width:100vw;
height:100vh;
position: fixed;
top: 0;
left:0;
display:none;
}
iframe{
width:100vw;
height:100vh;
}
@media screen and (orientation:portrait) {
#container2{
}
#container1{
}
iframe{
}
}
@media screen and (orientation:landscape) {
#container2{
}
#container1{
}
iframe{
}
}
</style>
</head>
<body>
<div id="container2"></div>
<div id="container1" ></div>
<div id="selectChatSource">
<h1>Which social integration are you adding?</h1>
</div>
<div id="clean">
<h1>Use VDO.Ninja and SocialStream chat at the same time</h1>
<input placeholder="Enter a VDON stream ID or VDON URL" id="viewlink" type="text" />
<input placeholder="Enter the SocialStream URL" id="social" type="text" />
<button onclick="loadIframes()" style="display:block;padding:10px;margin:10px;">START</button>
</div>
<script>
window.addEventListener("orientationchange", function() {
// Announce the new orientation number
// alert(window.orientation);
}, false);
function removeStorage(cname){
localStorage.removeItem(cname);
}
function setStorage(cname, cvalue, hours=9999){ // not actually a cookie
var now = new Date();
var item = {
value: cvalue,
expiry: now.getTime() + (hours * 60 * 60 * 1000),
};
try{
localStorage.setItem(cname, JSON.stringify(item));
}catch(e){errorlog(e);}
}
function getStorage(cname) {
try {
var itemStr = localStorage.getItem(cname);
} catch(e){
errorlog(e);
return;
}
if (!itemStr) {
return "";
}
var item = JSON.parse(itemStr);
var now = new Date();
if (now.getTime() > item.expiry) {
localStorage.removeItem(cname);
return "";
}
return item.value;
}
if (getStorage("SocialStreamChatLink")){
document.getElementById("social").value = getStorage("SocialStreamChatLink");
}
if (getStorage("vdoNinjaSocialStreamURL")){
document.getElementById("viewlink").value = getStorage("vdoNinjaSocialStreamURL");
}
function loadIframes(url=false){
var roomname = document.getElementById("viewlink").value;
var room2 = document.getElementById("social").value;
document.getElementById("clean").parentNode.removeChild(document.getElementById("clean"));
document.getElementById("container1").style.display="inline-block";
document.getElementById("container2").style.display="inline-block";
var path = window.location.host+window.location.pathname.split("/").slice(0,-1).join("/");
path = path.replace("/examples","");
if (roomname.startsWith("https://")){
var room1 = roomname;
} else {
var room1 = "https://"+path+"/?push="+roomname+"&webcam&autostart&vd=front&ad=1&transparent&noheader";
}
var iframe = document.createElement("iframe");
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
iframe.src = room1;
var iframeContainer = document.createElement("div");
iframeContainer.appendChild(iframe);
document.getElementById("container1").appendChild(iframeContainer);
setStorage("SocialStreamChatLink", room2);
setStorage("vdoNinjaSocialStreamURL", room1);
setTimeout(function(){
var iframe = document.createElement("iframe");
iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;";
iframe.src = room2;
var iframeContainer = document.createElement("div");
iframeContainer.appendChild(iframe);
document.getElementById("container2").appendChild(iframeContainer);
},3000);
}
</script>
</body>
</html>

View File

@@ -2,7 +2,7 @@
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OBSN Chat Overlay</title>
<title>VDON Chat Overlay</title>
<style>
@font-face {
@@ -147,21 +147,26 @@
return out;
}
function logData(type, data) {
var log = document.body.getElementsByTagName("ul")[0];
var entry = document.createElement('li');
if (type){
type = "<i>"+type+"</i>";
}
entry.innerHTML = type + data;
//setTimeout(function(entry){ // hide message after 60 seconds
// entry.innerHTML="";
// entry.remove();
// },60000,entry);
log.appendChild(entry);
}
function logData(type, data) {
var log = document.body.getElementsByTagName("ul")[0];
var entry = document.createElement('li');
if (type){
var typeElement = document.createElement("i");
typeElement.textContent = type;
entry.appendChild(typeElement);
entry.appendChild(document.createTextNode(" "));
}
var message = document.createElement("span");
message.textContent = data;
entry.appendChild(message);
//setTimeout(function(entry){ // hide message after 60 seconds
// entry.innerHTML="";
// entry.remove();
// },60000,entry);
log.appendChild(entry);
}
</script>
</head>
<body onload="loadIframe();">

209
examples/switchmics.html Normal file
View File

@@ -0,0 +1,209 @@
<html>
<head><title>Toggle Two Mutes Controller</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<style>
body{
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
color:white;
font-family: tahoma, arial;
}
a {
color:white
}
iframe {
width:100%;
height:100%;
border:0;
margin:0;
padding:0;
display:block;
}
input{
padding:10px;
width:80%;
font-size:1.2em;
z-index: 1000;
}
div{
border:0;
margin:0;
padding:0;
text-align: center;
}
button {
margin:40px;
padding:40px;
}
#container2 button{
margin: 5px min(10px, 1vh);
padding:10px min(100px, 10vh);
cursor:pointer;
height:100%;
font-size: max(30px, 4.5vh);
font-weight: 900;
}
iframe{
border:1px solid white;
}
span {
margin: 0 10px;
}
.selected{
border:3px solid lightblue;
}
</style>
</head>
<body>
<div id="container1" style="width:100%;height:89%;display:none;">
</div>
<div id="container2" style="width:100%;height:10%;display:none;">
<span>
<button id='a' onclick="mute('a')" style='background-color:green'>A</button>
<button id='c' onclick="mute('c')" style='background-color:red'>MUTE BOTH</button>
<button id='b' onclick="mute('b')" style='background-color:green'>B</button>
</span>
</div>
<div id="deleteme">
<h2>PTZ Remote Control interface</h2>
<input placeholder="Enter a push link. ie) https://vdo.ninja/?push=english123a&label=ENGLISH" id="viewlinka" type="text"><br>
<input placeholder="Enter a push link. ie) https://vdo.ninja/?push=french123b&label=FRENCH" id="viewlinkb" type="text" /><br>
<button onclick="loadIframes()">CONNECT</button>
<br>
This app is a toggle between two different inputs
</div>
<script>
var iframe;
function mute(target){
if (iframea && iframeb){
if (target=="a"){
document.getElementById("b").classList.remove("selected");
document.getElementById("c").classList.remove("selected");
document.getElementById("a").classList.add("selected");
iframea.contentWindow.postMessage({mic:true}, '*');
iframeb.contentWindow.postMessage({mic:false}, '*');
} else if (target=="b"){
document.getElementById("a").classList.remove("selected");
document.getElementById("c").classList.remove("selected");
document.getElementById("b").classList.add("selected");
iframea.contentWindow.postMessage({mic:false}, '*');
iframeb.contentWindow.postMessage({mic:true}, '*');
} else {
document.getElementById("a").classList.remove("selected");
document.getElementById("b").classList.remove("selected");
document.getElementById("c").classList.add("selected");
iframea.contentWindow.postMessage({mic:false}, '*');
iframeb.contentWindow.postMessage({mic:false}, '*');
}
}
}
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
if (urlEdited !== window.location.search){
warnlog(window.location.search + " changed to " + urlEdited);
window.history.pushState({path: urlEdited.toString()}, '', urlEdited.toString());
}
var urlParams = new URLSearchParams(urlEdited);
if (urlParams.get("a")){
if (window.location.host){
var path = window.location.host+window.location.pathname.replace("/examples/","/").split("/").slice(0,-1).join("/");
} else {
var path = "vdo.ninja";
}
document.getElementById("viewlinka").value = "https://"+path+"/?push="+urlParams.get("a");
if (urlParams.get("la")){
var labelA = urlParams.get("la");
document.getElementById("viewlinka").value += "&label="+encodeURIComponent(labelA);
document.getElementById("a").textContent = labelA;
}
}
if (urlParams.get("b")){
if (window.location.host){
var path = window.location.host+window.location.pathname.replace("/examples/","/").split("/").slice(0,-1).join("/");
} else {
var path = "vdo.ninja";
}
document.getElementById("viewlinkb").value = "https://"+path+"/?push="+urlParams.get("b");
if (urlParams.get("lb")){
var labelB = urlParams.get("lb");
document.getElementById("viewlinkb").value += "&label="+encodeURIComponent(labelB);
document.getElementById("b").textContent = labelB;
}
}
var iframeb = null;
var iframea = null;
function loadIframes(){
var iframesrca = document.getElementById("viewlinka").value;
var iframesrcb = document.getElementById("viewlinkb").value;
document.getElementById("deleteme").remove()
delete document.getElementById("deleteme");
document.getElementById("container1").style.display="flex";
document.getElementById("container2").style.display="inline-block";
var params = window.location.search || "";
if (iframesrca.includes("?")){
params = params.slice(1);
iframesrca = iframesrca + "&" + params
} else {
iframesrca = iframesrca + params
}
if (iframesrcb.includes("?")){
params = params.slice(1);
iframesrcb = iframesrcb + "&" + params
} else {
iframesrcb = iframesrcb + params
}
iframea = document.createElement("iframe");
iframea.allow = "encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;gyroscope;";
iframea.src = iframesrca;
document.getElementById("container1").appendChild(iframea);
iframeb = document.createElement("iframe");
iframeb.allow = "encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;gyroscope;";
iframeb.src = iframesrcb;
document.getElementById("container1").appendChild(iframeb);
}
window.addEventListener("message", function (e) {
if (iframeb && (e.source === iframeb.contentWindow)) {
console.log(e.data);
if (e.data.action == "this-label"){
document.getElementById("a").textContent = e.data.value;
}
} else if (iframea && (e.source === iframea.contentWindow)) {
if (e.data.action == "this-label"){
document.getElementById("b").textContent = e.data.value;
}
}
}, false);
</script>
</body>
</html>

289
examples/teleprompt.html Normal file
View File

@@ -0,0 +1,289 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<style>
html {
border:0;
margin:0;
outline:0;
overflow: hidden;
}
video {
margin: 0;
padding: 0;
overflow: hidden;
cursor: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=), none;
user-select: none;
}
body {
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
background-color: -webkit-linear-gradient(to top, #363644, 50%, #151b29); /* Chrome 10-25, Safari 5.1-6 */
background: linear-gradient(to top, #363644, 50%, #151b29); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
font-size: 2em;
font-family: Helvetica, Arial, sans-serif;
display: flex;
flex-flow: column;
border:0;
outline:0;
}
button.glyphicon-button:focus,
button.glyphicon-button:active:focus,
button.glyphicon-button.active:focus,
button.glyphicon-button.focus,
button.glyphicon-button:active.focus,
button.glyphicon-button.active.focus {
outline: none !important;
}
iframe {
border:0;
margin:0;
padding:0;
display:block;
width: 100vw;
height: calc(100vh - 100px);
transform: rotate(0deg);
transform-origin: 0 0;
left: 0;
position: absolute;
top: 100px;
}
.gobutton {
font-size:min(30px, 2vw);
font-weight: bold;
border: none;
background: #6aab23;
display: flex;
border-radius: 0px;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
box-shadow: 0 12px 15px -10px #5ca70b, 0 2px 0px #6aab23;
color: white;
cursor: pointer;
box-sizing: border-box;
align-items: center;
padding: 0 min(1vw, 10px);
margin: min(1vw, 10px) 0 ;
}
.details{
font-size: 14px;
font-weight: bold;
border: none;
background: #555;
display: flex;
border-radius: 0px;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
box-shadow: 0 12px 15px -10px #444, 0 2px 0px #555;
color: white;
box-sizing: border-box;
align-items: center;
padding: 0 min(1vw, 10px);
}
#header{
width:100%;
background-color: #101520;
}
.changeText {
font-size: max(1vw, 10px)
align-self: center;
width: 100%;
padding: min(1vw, 10px);
font-weight: bold;
background: white;
border: 4px solid white;
box-shadow: 0px 30px 40px -32px #6aab23, 0 2px 0px #6aab23;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
transition: all 0.2s linear;
box-sizing: border-box;
border-bottom-right-radius: 0;
border-top-right-radius: 0;
margin: min(1vw, 10px) 0;
}
.changeText:focus {
outline: none;
}
select.changetext{
padding: .1vw;
}
.container{
width:100%;
top:0;
position:absolute;
left:0;
margin: auto auto;
height: 70px;
}
label {
font: white;
font-size: 1vw;
color: white;
}
input[type='checkbox'] {
-webkit-appearance:none;
width:30px;
height:30px;
background:white;
border-radius:5px;
border:2px solid #555;
cursor: pointer;
}
input[type='checkbox']:checked {
background: #1A1;
}
#audioOutput, #lastUrls {
font-size: calc(16px + 0.3vw);
width: 730px;
height: 100%;
flex: 20;
border-radius: 10px;
padding: min(1vw, 10px);
background: #eaeaea;
cursor:pointer;
}
label[for="audioOutput"] {
font-size: min(30px, 2vw);
color: #FE53BB;
text-shadow: 0px 0px 30px #fe53bb;
padding-right: 10px;
}
label[for="changeText"] {
font-size: min(30px, 2.5vw);
color: #00F6FF;
text-shadow: 0px 0px 30px #00f6ff;
padding-right: 10px;
margin:auto auto;
}
label[for="lastUrls"] {
font-size: min(min(30px, 2vw), 2vw);
color: #1a1;
text-shadow: 0px 0px 30px #1a1;
padding-right: 10px;
cursor: pointer;
}
div#audioOutputContainer, #history {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
margin: 4em;
}
#messageDiv {
font-size: .7em;
color: #DDD;
transition: all 0.5s linear;
font-style: italic;
opacity: 0;
text-align: center;
margin: 10px 0;
}
div.urlInput {
padding: 0 0 4vh 0;
}
label[for="audioOutput"], label[for="lastUrls"] {
font-size: min(30px, 2vw);
}
#warning4mac, #electronVersion {
background: #8500f7;
box-shadow: 0px 0px 50px 10px #8500f7ab, inset 0px 0px 10px 2px #8d08ffba;
border: 2px solid #8500f7;
border-radius: 10px;
width: 90%;
padding:min(1vw, 10px);
margin:0 auto;
color:white;
font-size: 1.40px;
margin-bottom: 20px;
}
#warning4mac a, #electronVersion a {
color:white;
}
ul#lastUrls {
list-style: none;
background: #101520;
color: white;
padding: min(1vw, 10px);
}
ul#lastUrls li {
padding: 5px 0px;
}
ul#lastUrls li:nth-child(even) {
background-color: #182031;
}
.inputCombo {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
flex-grow: 1;
}
#version{
margin: 0 auto;
font-size: 30%;
display: inline-block;
color: #000A;
}
h3 {
color: #b0e3ff;
}
.hidden{
display:none;
opacity:0;
visibility:none;
width:0;
height:0
}
.hidebutton{
font-size:min(30px, 2vw);
font-weight: bold;
border: none;
background: #ab236a;
display: flex;
border-radius: 10px;
box-shadow: 0 12px 15px -10px #a70b5c, 0 2px 0px #ab236a;
color: white;
cursor: pointer;
box-sizing: border-box;
align-items: center;
padding: 0 min(1vw, 10px);
margin: min(1vw, 5px) 0;
}
</style>
</head>
<body>
<div class="container" id="container">
</div>
<script>
location.href = './teleprompter.html';
</script>
</body>
</html>

699
examples/teleprompter.html Normal file
View File

@@ -0,0 +1,699 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Teleprompter - VDO.Ninja</title>
<style>
html {
border:0;
margin:0;
outline:0;
overflow: hidden;
}
video {
margin: 0;
padding: 0;
overflow: hidden;
cursor: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=), none;
user-select: none;
}
body {
padding:0;
margin:0;
background-color:#003;
width:100%;
height:100%;
background-color: -webkit-linear-gradient(to top, #363644, 50%, #151b29); /* Chrome 10-25, Safari 5.1-6 */
background: linear-gradient(to top, #363644, 50%, #151b29); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
font-size: 2em;
font-family: Helvetica, Arial, sans-serif;
display: flex;
flex-flow: column;
border:0;
outline:0;
}
button.glyphicon-button:focus,
button.glyphicon-button:active:focus,
button.glyphicon-button.active:focus,
button.glyphicon-button.focus,
button.glyphicon-button:active.focus,
button.glyphicon-button.active.focus {
outline: none !important;
}
iframe {
border:0;
margin:0;
padding:0;
display:block;
width: 100vw;
height: calc(100vh - 100px);
transform: rotate(0deg);
transform-origin: 0 0;
left: 0;
position: absolute;
top: 100px;
}
.gobutton {
font-size:min(30px, 2vw);
font-weight: bold;
border: none;
background: #6aab23;
display: flex;
border-radius: 0px;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
box-shadow: 0 12px 15px -10px #5ca70b, 0 2px 0px #6aab23;
color: white;
cursor: pointer;
box-sizing: border-box;
align-items: center;
padding: 0 min(1vw, 10px);
margin: min(1vw, 10px) 0 ;
}
.details{
font-size: 14px;
font-weight: bold;
border: none;
background: #555;
display: flex;
border-radius: 0px;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
box-shadow: 0 12px 15px -10px #444, 0 2px 0px #555;
color: white;
box-sizing: border-box;
align-items: center;
padding: 0 min(1vw, 10px);
}
#header{
width:100%;
background-color: #101520;
}
.changeText {
font-size: max(1vw, 10px)
align-self: center;
width: 100%;
padding: min(1vw, 10px);
font-weight: bold;
background: white;
border: 4px solid white;
box-shadow: 0px 30px 40px -32px #6aab23, 0 2px 0px #6aab23;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
transition: all 0.2s linear;
box-sizing: border-box;
border-bottom-right-radius: 0;
border-top-right-radius: 0;
margin: min(1vw, 10px) 0;
}
.changeText:focus {
outline: none;
}
select.changetext{
padding: .1vw;
}
.container{
width:100%;
top:0;
position:absolute;
left:0;
margin: auto auto;
height: 70px;
}
label {
font: white;
font-size: 1vw;
color: white;
}
input[type='checkbox'] {
-webkit-appearance:none;
width:30px;
height:30px;
background:white;
border-radius:5px;
border:2px solid #555;
cursor: pointer;
}
input[type='checkbox']:checked {
background: #1A1;
}
#audioOutput, #lastUrls {
font-size: calc(16px + 0.3vw);
width: 730px;
height: 100%;
flex: 20;
border-radius: 10px;
padding: min(1vw, 10px);
background: #eaeaea;
cursor:pointer;
}
label[for="audioOutput"] {
font-size: min(30px, 2vw);
color: #FE53BB;
text-shadow: 0px 0px 30px #fe53bb;
padding-right: 10px;
}
label[for="changeText"] {
font-size: min(30px, 2.5vw);
color: #00F6FF;
text-shadow: 0px 0px 30px #00f6ff;
padding-right: 10px;
margin:auto auto;
}
label[for="lastUrls"] {
font-size: min(min(30px, 2vw), 2vw);
color: #1a1;
text-shadow: 0px 0px 30px #1a1;
padding-right: 10px;
cursor: pointer;
}
div#audioOutputContainer, #history {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
margin: 4em;
}
#messageDiv {
font-size: .7em;
color: #DDD;
transition: all 0.5s linear;
font-style: italic;
opacity: 0;
text-align: center;
margin: 10px 0;
}
div.urlInput {
padding: 0 0 4vh 0;
}
label[for="audioOutput"], label[for="lastUrls"] {
font-size: min(30px, 2vw);
}
#warning4mac, #electronVersion {
background: #8500f7;
box-shadow: 0px 0px 50px 10px #8500f7ab, inset 0px 0px 10px 2px #8d08ffba;
border: 2px solid #8500f7;
border-radius: 10px;
width: 90%;
padding:min(1vw, 10px);
margin:0 auto;
color:white;
font-size: 1.40px;
margin-bottom: 20px;
}
#warning4mac a, #electronVersion a {
color:white;
}
ul#lastUrls {
list-style: none;
background: #101520;
color: white;
padding: min(1vw, 10px);
}
ul#lastUrls li {
padding: 5px 0px;
}
ul#lastUrls li:nth-child(even) {
background-color: #182031;
}
.inputCombo {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
flex-grow: 1;
}
#version{
margin: 0 auto;
font-size: 30%;
display: inline-block;
color: #000A;
}
h3 {
color: #b0e3ff;
}
.hidden{
display:none;
opacity:0;
visibility:none;
width:0;
height:0
}
.hidebutton{
font-size:min(30px, 2vw);
font-weight: bold;
border: none;
background: #ab236a;
display: flex;
border-radius: 10px;
box-shadow: 0 12px 15px -10px #a70b5c, 0 2px 0px #ab236a;
color: white;
cursor: pointer;
box-sizing: border-box;
align-items: center;
padding: 0 min(1vw, 10px);
margin: min(1vw, 5px) 0;
}
</style>
</head>
<body>
<div class="container" id="container">
<div style="display:inline-block;position:absolute;width:max(calc(100vw - 178px), 50%);">
<div class="inputCombo" id="inputCombo1">
<label for="changeText">
🔗
</label>
<input type="text" id="iframeURL" onchange="updatedURL();" class="inputfield changeText" placeholder="Website URL to transform. ie: https://vdo.ninja" />
<input type="text" id="iframeURL_twitch" onchange="updatedURL();" class="hidden inputfield changeText" placeholder="Twitch Username; their chat will load" />
<input type="text" id="iframeURL_youtube" onchange="updatedURL();" class="hidden inputfield changeText" placeholder="Youtube Username; will try to load chat" />
<button onclick="gohere1();" class="gobutton" id="gobutton1">Load</button>
<select style="border-radius:10px;margin-left:10px;margin-top: 13px;width:unset!important;" onchange="updateType();" class="changeText" id="sourceType" title="Which video codec would you prefer to be used if available?" >
<option value="url" selected>Website URL</option>
<option value="twitch">Twitch</option>
</select >
<select style="border-radius:10px;margin-left:5px;margin-top: 13px;width:unset!important;" onchange="rotatePage();" class="changeText" id="rotation" title="Which video bitrate target would you prefer?" >
<option value="0" selected>No Rotation</option>
<option value="90">90° CW</option>
<option value="180">180° CW</option>
<option value="270">270° CW</option>
</select >
<select style="border-radius:10px;margin-left:5px;margin-top: 13px;width:unset!important;" onchange="rotatePage();" class="changeText" id="transform" title="Which video codec would you prefer to be used if available?" >
<option value="0" selected>No Transform</option>
<option value="1">🪞Mirror</option>
<option value="2">🙃Flip</option>
</select >
</div>
</div>
<div style="display:inline-block;position:absolute;right:0;">
<div class="inputCombo" id="advanced2" style="margin: 10px 0px 0px 10px;">
<button onclick="hidebar();" class="hidebutton">Hide Menu</button>
</div>
</div>
</div>
<script>
var domain = "./";
var urlParams = new URLSearchParams(window.location.search);
var iframe = document.createElement("iframe");
if (urlParams.has("rotate")){
document.querySelector("#rotation").value = urlParams.get("rotate") || 0;
} else if (localStorage.getItem('rotation')){
document.querySelector("#rotation").value = localStorage.getItem('rotation') || 0;
}
if (urlParams.has("flip")){
document.querySelector("#transform").value = urlParams.get("flip") || 0;
} else if (localStorage.getItem('transform')){
document.querySelector("#transform").value = localStorage.getItem('transform') || 0;
}
if (localStorage.getItem('sourceType')){
document.querySelector("#sourceType").value = localStorage.getItem('sourceType') || "url";
updateType();
}
if (localStorage.getItem('iframeURL') || urlParams.has("link")){
try
document.querySelector("#iframeURL").value = decodeURIComponent(urlParams.get("link") || "") || localStorage.getItem('iframeURL') || "";
} catch(e){
document.querySelector("#iframeURL").value = urlParams.get("link") || localStorage.getItem('iframeURL') || "";
}
}
if (localStorage.getItem('iframeURL_twitch') || urlParams.has("twitch")){
try
document.querySelector("#iframeURL_twitch").value = decodeURIComponent(urlParams.get("twitch") || "") || localStorage.getItem('iframeURL_twitch') || "";
} catch(e){
document.querySelector("#iframeURL_twitch").value = urlParams.get("twitch") || localStorage.getItem('iframeURL_twitch') || "";
}
}
if (localStorage.getItem('iframeURL_youtube') || urlParams.has("youtube")){
try
document.querySelector("#iframeURL").value = decodeURIComponent(urlParams.get("youtube") || "") || localStorage.getItem('iframeURL_youtube') || "";
} catch(e){
document.querySelector("#iframeURL").value = urlParams.get("youtube") || localStorage.getItem('iframeURL_youtube') || "";
}
}
var menuOffset = "70px";
if ( urlParams.has("hidemenu") || urlParams.has("hide") || urlParams.has("hidebar")){
container.classList.add("hidden");
menuOffset = "0px";
}
loadPage();
function hidebar(){
container.classList.add("hidden");
menuOffset = "0px";
loadPage();
}
function gohere1(){
localStorage.setItem('iframeURL', document.getElementById('iframeURL').value);
localStorage.setItem('iframeURL_twitch', document.getElementById('iframeURL_twitch').value);
localStorage.setItem('iframeURL_youtube', document.getElementById('iframeURL_youtube').value);
localStorage.setItem('rotation', document.getElementById('rotation').value);
localStorage.setItem('transform', document.getElementById('transform').value);
localStorage.setItem('sourceType', document.getElementById('sourceType').value);
loadPage()
}
function updateType(){
localStorage.setItem('sourceType', document.getElementById('sourceType').value);
document.getElementById('iframeURL').classList.add('hidden');
document.getElementById('iframeURL_twitch').classList.add('hidden');
document.getElementById('iframeURL_youtube').classList.add('hidden');
if ( document.getElementById('sourceType').value=="url"){
document.getElementById('iframeURL').classList.remove('hidden');
} else if ( document.getElementById('sourceType').value=="twitch"){
document.getElementById('iframeURL_twitch').classList.remove('hidden');
} else if ( document.getElementById('sourceType').value=="youtube"){
document.getElementById('iframeURL_youtube').classList.remove('hidden');
}
}
function updatedURL(){
if ( document.getElementById('iframeURL').value==""){
localStorage.setItem('iframeURL', document.getElementById('iframeURL').value);
}
if ( document.getElementById('iframeURL_twitch').value==""){
localStorage.setItem('iframeURL_twitch', document.getElementById('iframeURL_twitch').value);
}
if ( document.getElementById('iframeURL_youtube').value==""){
localStorage.setItem('iframeURL_youtube', document.getElementById('iframeURL_youtube').value);
}
}
function resetHistory(){
localStorage.clear();
document.querySelector("#iframeURL").value = "";
document.querySelector("#rotation").value = "";
document.querySelector("#transform").value = "";
}
(function (w) {
w.URLSearchParams = w.URLSearchParams || function (searchString) {
var self = this;
self.searchString = searchString;
self.get = function (name) {
var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(self.searchString);
if (results == null) {
return null;
}
else {
return decodeURI(results[1]) || 0;
}
};
}
})(window)
function enterPressed(event, callback){
if (event.keyCode === 13){ // Number 13 is the "Enter" key on the keyboard
event.preventDefault(); // Cancel the default action, if needed
callback();
}
}
var isMobile = false;
if( /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)){ // does not detect iPad Pros.
isMobile=true; // if iOS, default to H264? meh. let's not.
}
async function loadPage(){
var iframeURL = document.getElementById('iframeURL').value;
if ( document.getElementById('sourceType').value=="twitch"){
iframeURL = document.getElementById('iframeURL_twitch').value;
if (!iframeURL.length){
iframe.src = "";
return;
}
iframeURL = "https://www.twitch.tv/popout/"+iframeURL+"/chat?darkpopout&popout=";
} else if ( document.getElementById('sourceType').value=="youtube"){
iframeURL = document.getElementById('iframeURL_youtube').value;
if (!iframeURL.length){
iframe.src = "";
return;
}
if (!iframeURL.startsWith("@")){
iframeURL = "@"+iframeURL;
}
try{
var response = await fetch("https://www.youtube.com/c/"+iframeURL+"/live");
var data = await response.text();
let videoID = data.split('{"videoId":"')[1].split('"')[0];
console.log(videoID);
iframeURL = "https://www.youtube.com/live_chat?is_popout=1&v="+videoID;
} catch(e){
alert("This Youtube user isn't live or is set to private");
return;
}
}
if (!iframeURL.length){
iframe.src = "";
return;
}
if (!(iframeURL.startsWith("file:") || iframeURL.startsWith("./") || iframeURL.startsWith("http://") || iframeURL.startsWith("https://"))){
iframeURL = "https://"+iframeURL;
}
var domain = (new URL(iframeURL));
domain = domain.hostname;
if (domain == "youtu.be"){
iframeURL = iframeURL.replace("youtu.be/","youtube.com/watch?v=");
}
if ((domain == "youtu.be") || (domain=="www.youtube.com") || (domain=="youtube.com")){
var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
var match = iframeURL.match(regExp);
var vidid = (match&&match[7].length==11)? match[7] : false;
// https://www.youtube.com/live_chat?v=<your video ID>&embed_domain=<your blog domain>
if (iframeURL.includes("/live_chat")){
if (!iframeURL.includes("&embed_domain=")){
iframeURL += "&embed_domain="+location.hostname;
}
}
if (vidid){
//specialResult = {};
//specialResult.originalSrc = iframeURL;
//specialResult.parsedSrc = "https://www.youtube.com/embed/"+vidid+"?autoplay=1&modestbranding=1&playsinline=1&enablejsapi=1";
//specialResult.handler = "youtube";
//specialResult.vid = vidid;
//iframeURL = specialResult;
iframeURL = createYoutubeLink(vidid);
} else { // see if there is a playlist link here or not.
// https://youtube.com/playlist?list=PLWodc2tCfAH1l_LDvEyxEqFf42hOBKqQM
iframeURL = iframeURL.replace("playlist?list=","embed/videoseries?list=");
var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(videoseries\?))\??list?=?([^#&?]*).*/;
var match = iframeURL.match(regExp);
var plid = (match&&match[7].length==34)? match[7] : false;
if (plid){
iframeURL = 'https://www.youtube.com/embed/videoseries?list='+plid+"&autoplay=1&modestbranding=1&playsinline=1&enablejsapi=1";
}
}
} else if ((domain=="twitch.tv") || (domain=="www.twitch.tv")){
if (iframeURL.includes("/embed/")){
// skip
} else if (iframeURL.includes("twitch.tv/popout/")){
// this is a twitch live chat window
iframeURL = iframeURL.replace("/popout/","/embed/");
iframeURL = iframeURL.replace("?popout=","?parent="+location.hostname);
iframeURL = iframeURL.replace("?popout","?parent="+location.hostname);
if (!iframeURL.includes("chat?")){
iframeURL = iframeURL.replace("&popout=","?parent="+location.hostname);
iframeURL = iframeURL.replace("&popout","?parent="+location.hostname);
}
if (iframeURL.includes("darkpopout=")){
iframeURL = iframeURL.replace("?darkpopout=","?darkpopout=&parent="+location.hostname);
} else if (!iframeURL.includes("?parent=")){
iframeURL = iframeURL.replace("?darkpopout","?darkpopout&parent="+location.hostname);
}
} else {
var vidid = iframeURL.split('/').pop().split('#')[0].split('?')[0];
if (vidid){
iframeURL = "https://player.twitch.tv/?channel="+vidid+"&parent="+location.hostname;
}
}
} else if ((domain=="www.vimeo.com") || (domain=="vimeo.com")){
iframeURL = iframeURL.replace("//vimeo.com/","//player.vimeo.com/video/");
iframeURL = iframeURL.replace("//www.vimeo.com/","//player.vimeo.com/video/");
} else if (domain.includes("tiktok.com")){
var split = iframeURL.split("/video/");
if (split.length>1){
split = split[1].split("/")[0].split("?")[0].split("#")[0];
iframeURL = "https://www.tiktok.com/embed/v2/" + split;
}
}
var urlEdited = window.location.search.replace(/\?\?/g, "?");
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
if (urlEdited !== window.location.search){
if (!urlParams.has("link")){
urlEdited += "&link="+ encodeURIComponent(iframeURL);
}
urlEdited = urlEdited.replace(/\?/g, "&");
urlEdited = urlEdited.replace(/\&/, "?");
window.history.pushState({path: urlEdited.toString()}, '', urlEdited.toString());
}
iframe.allow = "encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;accelerometer;midi;geolocation;autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;";
iframe.src = iframeURL;
rotatePage();
// document.body.innerHTML = "";
document.body.appendChild(iframe);
}
function rotatePage(){
var rotate = document.querySelector("#rotation").value || 0;
var flip = document.querySelector("#transform").value;
localStorage.setItem('iframeURL', document.getElementById('iframeURL').value);
localStorage.setItem('rotation', document.getElementById('rotation').value);
//localStorage.setItem('backgroundColor', document.getElementById('backgroundColor').value);
localStorage.setItem('transform', document.getElementById('transform').value);
if (rotate==180){
iframe.style.transform = "rotate(180deg)";
iframe.style.width = "100vw";
iframe.style.height = "calc(100vh - "+menuOffset+")";
iframe.style.transformOrigin = "0 0;";
iframe.style.position = "rotate(180deg)";
iframe.style.left = "100vw";
iframe.style.top = "calc(100vh)";
if (flip==1){
iframe.style.transform += " scaleX(-1)";
iframe.style.top = "calc(" + (iframe.style.top) +" + )";
iframe.style.left = "calc(" + (iframe.style.left) +" - 100vw)";
} else if (flip==2){
iframe.style.transform += " scaleY(-1)";
iframe.style.top = "calc(" + (iframe.style.top) +" + "+menuOffset+" - 100vh)";
iframe.style.left = "calc(" + (iframe.style.left) +" )";
}
} else if (rotate==270){
iframe.style.transform = "rotate(270deg)";
iframe.style.left = "0";
iframe.style.top = "100vh";
iframe.style.transformOrigin = "0 0;";
iframe.style.width = "calc(100vh - "+menuOffset+")";
iframe.style.height = "100vw";
if (flip==1){
iframe.style.transform += " scaleX(-1)";
iframe.style.top = "calc(" + (iframe.style.top) +" + "+menuOffset+" - 100vh)";
iframe.style.left = "calc(" + (iframe.style.left) +" )";
} else if (flip==2){
iframe.style.transform += " scaleY(-1)";
iframe.style.top = "calc(" + (iframe.style.top) +" )";
iframe.style.left = "calc(" + (iframe.style.left) +" + 100vw)";
}
} else if (rotate==90){
iframe.style.transform = "rotate(90deg)";
iframe.style.width = "calc(100vh - "+menuOffset+")";
iframe.style.height = "100vw";
iframe.style.transformOrigin = "0 0;";
iframe.style.left = "calc(100vw";
iframe.style.top = menuOffset;
if (flip==1){
iframe.style.transform += " scaleX(-1)";
iframe.style.top = "calc(" + (iframe.style.top) +" - "+menuOffset+" + 100vh)";
iframe.style.left = "calc(" + (iframe.style.left) +" )";
} else if (flip==2){
iframe.style.transform += " scaleY(-1)";
iframe.style.top = "calc(" + (iframe.style.top) +" )";
iframe.style.left = "calc(" + (iframe.style.left) +" - 100vw)";
}
} else {
iframe.style.transform = "rotate(0deg)";
iframe.style.width = "100vw";
iframe.style.height = "calc(100vh - "+menuOffset+")";
iframe.style.transformOrigin = "0 0;";
iframe.style.position = "rotate(0deg)";
iframe.style.left = "0";
iframe.style.top = menuOffset;
if (flip==1){
iframe.style.transform += " scaleX(-1)";
iframe.style.top = "calc(" + (iframe.style.top) +" )";
iframe.style.left = "calc(" + (iframe.style.left) +" + 100vw)";
} else if (flip==2){
iframe.style.transform += " scaleY(-1)";
iframe.style.top = "calc( 100vh)";
//iframe.style.left = "calc(" + (iframe.style.left) +" + 100vw)";
}
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<html>
<head>
<style>
body {
background-color:#0000;
object-fit: contain;
width: 100%;
height: 100%;
overflow: hidden;
}
img {
object-fit: contain;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
</head>
<body>
<img src='../media/vdoNinja_logo_full.png'>
</body>
</html>

1072
examples/testsdp.html Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More