mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
Compare commits
791 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48995c8f8 | ||
|
|
de54cd456a | ||
|
|
90eda40006 | ||
|
|
a226a52f34 | ||
|
|
c82e3d6418 | ||
|
|
c599d14c3b | ||
|
|
ff57c065ee | ||
|
|
a631bc074c | ||
|
|
189b8350f2 | ||
|
|
6162959292 | ||
|
|
8a5cba72c3 | ||
|
|
aabf1bb560 | ||
|
|
19984075f3 | ||
|
|
c7be6d88d2 | ||
|
|
f3d18ad3ee | ||
|
|
54a16a324d | ||
|
|
552c5cfc91 | ||
|
|
b078f20e35 | ||
|
|
4c6d4eb22b | ||
|
|
9e6cd6dc6f | ||
|
|
fa3df7a160 | ||
|
|
c71061f598 | ||
|
|
6a1309879d | ||
|
|
0071496903 | ||
|
|
d6789af59b | ||
|
|
2fa7f629be | ||
|
|
20c815e607 | ||
|
|
83c0ac753a | ||
|
|
0dbe6cfb7e | ||
|
|
d3bac99d70 | ||
|
|
89cc7205a8 | ||
|
|
5dbd999a9a | ||
|
|
1eeb025378 | ||
|
|
ca3757ee75 | ||
|
|
bd002bf135 | ||
|
|
728e198810 | ||
|
|
45f80890fa | ||
|
|
6ea075b67c | ||
|
|
f3acd0877c | ||
|
|
97f016d723 | ||
|
|
dc0e2d02c4 | ||
|
|
08dfceacdd | ||
|
|
eb21781b23 | ||
|
|
fea860ab90 | ||
|
|
cf8bf079fa | ||
|
|
c4a37895c8 | ||
|
|
976c7627fd | ||
|
|
8ee8d64619 | ||
|
|
c1b24bbc39 | ||
|
|
416daa2d8b | ||
|
|
67685d99e7 | ||
|
|
fac763e14c | ||
|
|
9c996318c0 | ||
|
|
90008b15fa | ||
|
|
2464ac98b9 | ||
|
|
c798bb5c96 | ||
|
|
9db8195421 | ||
|
|
383343851e | ||
|
|
2b7acdac8b | ||
|
|
c0bfb22ef2 | ||
|
|
1168962436 | ||
|
|
19310ddeed | ||
|
|
b5fba7d6e7 | ||
|
|
2335172c23 | ||
|
|
71b15a94dd | ||
|
|
c9380616de | ||
|
|
4523a0bb83 | ||
|
|
f3580248cd | ||
|
|
897e20068c | ||
|
|
239a3a6d05 | ||
|
|
ca289d1bbb | ||
|
|
ea7cbeb4f1 | ||
|
|
e11326c99d | ||
|
|
eccb7119d4 | ||
|
|
4c570889bf | ||
|
|
5aee4d3bdb | ||
|
|
98f9687c6a | ||
|
|
51cdebe934 | ||
|
|
7b72847cc1 | ||
|
|
b61a915add | ||
|
|
a3cf82d15d | ||
|
|
23b5434d9b | ||
|
|
91fed124ca | ||
|
|
c6ceb430de | ||
|
|
3619cee3ed | ||
|
|
2c67bdb773 | ||
|
|
1faebe7809 | ||
|
|
9956c77cf2 | ||
|
|
5b4482ec0d | ||
|
|
43945838f0 | ||
|
|
89ac01621b | ||
|
|
b1a70e7fb5 | ||
|
|
c3c11ff23a | ||
|
|
c315b43a48 | ||
|
|
ec0c44a046 | ||
|
|
d4851c9862 | ||
|
|
222e91e64a | ||
|
|
98c7354caa | ||
|
|
3f7f7ff220 | ||
|
|
e8b7822d19 | ||
|
|
ec02e9b233 | ||
|
|
ef9eb8af1b | ||
|
|
9c584a9f8d | ||
|
|
b3054d062e | ||
|
|
81e323b37f | ||
|
|
5acc14eaf5 | ||
|
|
b52421a2e2 | ||
|
|
d2cb661c9e | ||
|
|
9f978ff4ef | ||
|
|
1358ba6299 | ||
|
|
672bf89dec | ||
|
|
098740f67b | ||
|
|
cd33796ca7 | ||
|
|
f1fd5d4a06 | ||
|
|
bc96ee5f4a | ||
|
|
6f06a8a8e8 | ||
|
|
c1c4a0def1 | ||
|
|
9ef61ee12f | ||
|
|
80c170dd69 | ||
|
|
b17ab24bdc | ||
|
|
7d44e1c457 | ||
|
|
bfd8d17e91 | ||
|
|
caae81985d | ||
|
|
34bfd80c41 | ||
|
|
0b68ac1f9b | ||
|
|
b1c1385142 | ||
|
|
f72063c0ce | ||
|
|
e6c255c84f | ||
|
|
03a5329e54 | ||
|
|
28caf91333 | ||
|
|
e2ae727e02 | ||
|
|
d0ec18d77e | ||
|
|
3a30dd3bbb | ||
|
|
44ef4a81cf | ||
|
|
9968cd0b0e | ||
|
|
b700bb5e22 | ||
|
|
2cd7eda4ac | ||
|
|
221760374b | ||
|
|
497a99e45c | ||
|
|
1c9c86949b | ||
|
|
74be8a9008 | ||
|
|
e93c2eb090 | ||
|
|
46fb393b94 | ||
|
|
e512cc4b1e | ||
|
|
3a4ecd0dda | ||
|
|
7cb322af80 | ||
|
|
c055bfea4c | ||
|
|
f8444c3120 | ||
|
|
5d43e71cef | ||
|
|
0b9ae1a655 | ||
|
|
1381729cd4 | ||
|
|
6221f5f292 | ||
|
|
24aea29932 | ||
|
|
8d54e68cab | ||
|
|
52351d9a34 | ||
|
|
4777522239 | ||
|
|
d91cde97de | ||
|
|
fdc710164c | ||
|
|
f206ab5bab | ||
|
|
37accc36cf | ||
|
|
c736c1f64e | ||
|
|
8f51d03f9d | ||
|
|
8cc898b965 | ||
|
|
4fa58115a3 | ||
|
|
f3425d43ea | ||
|
|
f81e9364ba | ||
|
|
730b776273 | ||
|
|
b888e0a674 | ||
|
|
86ab7ce458 | ||
|
|
2f1d343cbe | ||
|
|
c1790af404 | ||
|
|
678ca4e693 | ||
|
|
dca3d95532 | ||
|
|
2e1de4126f | ||
|
|
c87dedd002 | ||
|
|
d1ccb8a6b0 | ||
|
|
43fd698ff7 | ||
|
|
e317fe82ab | ||
|
|
369b544722 | ||
|
|
e3bf8c7393 | ||
|
|
b1ee02d53e | ||
|
|
655584bf39 | ||
|
|
113516a2be | ||
|
|
b2bbc933a8 | ||
|
|
0230e0aad8 | ||
|
|
d52b762f90 | ||
|
|
df389c0cd9 | ||
|
|
c6c1c74b35 | ||
|
|
a8683bb9b7 | ||
|
|
a6da4bbf45 | ||
|
|
9da9870b55 | ||
|
|
61c18bfba8 | ||
|
|
46f5eeec62 | ||
|
|
80c332e520 | ||
|
|
b0e453cea5 | ||
|
|
b8dfab2dbc | ||
|
|
8913ea29c5 | ||
|
|
1d859cd3ec | ||
|
|
bc7cc07438 | ||
|
|
77c1894483 | ||
|
|
519bb856d9 | ||
|
|
04c1f3eac1 | ||
|
|
2937723961 | ||
|
|
8a18d96532 | ||
|
|
202e5fe8c2 | ||
|
|
7d9632aa9d | ||
|
|
80aa9a3d5d | ||
|
|
8a57a3f0f3 | ||
|
|
627a88cf5e | ||
|
|
d82ab92df1 | ||
|
|
4a5f7690cd | ||
|
|
ae19ce9564 | ||
|
|
b2224a31b5 | ||
|
|
73464d6330 | ||
|
|
b105d26242 | ||
|
|
90ff4619e5 | ||
|
|
af67ac1428 | ||
|
|
a11012c8bd | ||
|
|
99ea0985dc | ||
|
|
e4e2707716 | ||
|
|
48fbc9e676 | ||
|
|
f1509c9867 | ||
|
|
edeb83a180 | ||
|
|
7dd5f6fe58 | ||
|
|
8e59863e91 | ||
|
|
32a3cc969f | ||
|
|
48d4494a2a | ||
|
|
baa5d78b7b | ||
|
|
35ad8832fe | ||
|
|
8bcc9ac23c | ||
|
|
7072d32f58 | ||
|
|
4845391035 | ||
|
|
e54b2d1fd9 | ||
|
|
4c8b806e4b | ||
|
|
01d7958c93 | ||
|
|
af60dab4db | ||
|
|
3230e961e3 | ||
|
|
d0e0d7e04d | ||
|
|
2a24446848 | ||
|
|
0a53b23195 | ||
|
|
782ccda08f | ||
|
|
a930d77d08 | ||
|
|
1a6378edbb | ||
|
|
7694dec33a | ||
|
|
883bd18410 | ||
|
|
7154b90c03 | ||
|
|
676d831e20 | ||
|
|
27a3f661d8 | ||
|
|
e3aa75e81c | ||
|
|
32ce9cc53e | ||
|
|
0f2e662056 | ||
|
|
2c8ae58d68 | ||
|
|
ada283aa69 | ||
|
|
4c4d58c8e6 | ||
|
|
9972fa89e5 | ||
|
|
4058553964 | ||
|
|
96d8a1bfa4 | ||
|
|
518fd4f8f9 | ||
|
|
4749fe3bbf | ||
|
|
4dde5190e1 | ||
|
|
9b8ec9adb3 | ||
|
|
db09fe5880 | ||
|
|
9bd5d39eb3 | ||
|
|
08ab1cde53 | ||
|
|
43c0854ae5 | ||
|
|
640fb7b0db | ||
|
|
dbb50f20cb | ||
|
|
5d62f3e287 | ||
|
|
c3927d7d2e | ||
|
|
a6d7b237c2 | ||
|
|
80404d5a92 | ||
|
|
16fc486476 | ||
|
|
75469d9e3f | ||
|
|
50a86c31f7 | ||
|
|
527929cf95 | ||
|
|
5a42948014 | ||
|
|
7ee9653dfd | ||
|
|
70262002db | ||
|
|
500d2ecf34 | ||
|
|
ed1716c23c | ||
|
|
49c7590123 | ||
|
|
e7909b55d2 | ||
|
|
04ccbc3180 | ||
|
|
4b5ed72722 | ||
|
|
aadc5fbf3e | ||
|
|
0afd701fb9 | ||
|
|
a7bd36e46b | ||
|
|
e8a672de77 | ||
|
|
9f1130daff | ||
|
|
74505e8cd0 | ||
|
|
55414d1133 | ||
|
|
a81c113b91 | ||
|
|
b66a7ac3f3 | ||
|
|
d1ff359c60 | ||
|
|
b2364edcf0 | ||
|
|
beff7c25df | ||
|
|
c4c2d5aaae | ||
|
|
3b5ff207d0 | ||
|
|
c6dbbb856f | ||
|
|
50cc8e2077 | ||
|
|
44c56e42a5 | ||
|
|
7ac7eb72c5 | ||
|
|
0953ffc99c | ||
|
|
fdf4c18bb5 | ||
|
|
c451fa1b20 | ||
|
|
96b0dd420b | ||
|
|
175a7c8672 | ||
|
|
60806da160 | ||
|
|
1658344e9a | ||
|
|
e3c00ad539 | ||
|
|
67ca08c677 | ||
|
|
186bdd1862 | ||
|
|
4b73012930 | ||
|
|
329121c395 | ||
|
|
361ab7a3f6 | ||
|
|
257d2c8468 | ||
|
|
ed9a0b3d93 | ||
|
|
60283c8ba4 | ||
|
|
510a18ba3f | ||
|
|
4db46891bd | ||
|
|
d39b8fa001 | ||
|
|
90dc678e90 | ||
|
|
08a12c5045 | ||
|
|
8eb63b1f37 | ||
|
|
30cafd5dd3 | ||
|
|
aa51dc4333 | ||
|
|
a47baccbf8 | ||
|
|
74a693af50 | ||
|
|
a2141b48a0 | ||
|
|
cc48729d8a | ||
|
|
6521dde49d | ||
|
|
0dc41e40fd | ||
|
|
bbb2616e60 | ||
|
|
86b91582ea | ||
|
|
100330a0de | ||
|
|
e0620aecbd | ||
|
|
c18e0d4e53 | ||
|
|
ffd2debc60 | ||
|
|
47606397a9 | ||
|
|
5d1db70180 | ||
|
|
bf101cc9ac | ||
|
|
0b137c69f3 | ||
|
|
a0be92c1fe | ||
|
|
9c60f5f970 | ||
|
|
1a9d9e2d3c | ||
|
|
e544bcbbfa | ||
|
|
54ab0f19dc | ||
|
|
d8282852f3 | ||
|
|
bdcecbe754 | ||
|
|
05b9c3faaf | ||
|
|
1ca13c8d85 | ||
|
|
e8702afea9 | ||
|
|
0a88b24b97 | ||
|
|
c3cbd15b3b | ||
|
|
ad053cd2e2 | ||
|
|
351c00baec | ||
|
|
fbe6feb4d4 | ||
|
|
ebc8370a59 | ||
|
|
986fba8b76 | ||
|
|
dd807f15ac | ||
|
|
ca4411faa8 | ||
|
|
284e854170 | ||
|
|
6de96c4d9a | ||
|
|
167eeb7e76 | ||
|
|
c184e6cb7f | ||
|
|
e167c8dbd2 | ||
|
|
c421498892 | ||
|
|
f73bfe5375 | ||
|
|
e466716357 | ||
|
|
ef75e97571 | ||
|
|
8084afc60f | ||
|
|
3c5e05d477 | ||
|
|
a0e6d95453 | ||
|
|
eee580d58e | ||
|
|
622e87d3d7 | ||
|
|
c78e15c62b | ||
|
|
2c1de1080c | ||
|
|
8fca5a0c33 | ||
|
|
4b31ae71dd | ||
|
|
e6105aca55 | ||
|
|
b8b773dcb1 | ||
|
|
fe4c59c230 | ||
|
|
119ed82eda | ||
|
|
4fe031fc6e | ||
|
|
fdcd2ae11a | ||
|
|
4c2f2e236c | ||
|
|
021baa76e1 | ||
|
|
1279857f43 | ||
|
|
ddba2484f7 | ||
|
|
8ec4acb9ca | ||
|
|
de5682a416 | ||
|
|
f7e363d215 | ||
|
|
dc759f7d87 | ||
|
|
c4aca7dd43 | ||
|
|
f3820ca6ac | ||
|
|
76a1a3ef35 | ||
|
|
bfa4bc8358 | ||
|
|
40ba5bc6c5 | ||
|
|
4941f7df64 | ||
|
|
bfa519d1ac | ||
|
|
824ed2a42d | ||
|
|
4bf561a1e6 | ||
|
|
00bcdf8743 | ||
|
|
c80a649784 | ||
|
|
8a9b62eaec | ||
|
|
2a3237a3ba | ||
|
|
67e0eda31a | ||
|
|
8d0a37ea54 | ||
|
|
f9e383290b | ||
|
|
3b48b541ad | ||
|
|
b58253f4d4 | ||
|
|
c49d24d77a | ||
|
|
d4adf75895 | ||
|
|
7e3c2983f3 | ||
|
|
04f454aabf | ||
|
|
484fde4b4e | ||
|
|
28d2e74064 | ||
|
|
a232a6550e | ||
|
|
2860b7c151 | ||
|
|
09d3face07 | ||
|
|
7d550e745a | ||
|
|
3ee53e4439 | ||
|
|
3fa6a7b653 | ||
|
|
0a24ee1dfc | ||
|
|
e6dc40fc38 | ||
|
|
92ecba20f6 | ||
|
|
bf65917a64 | ||
|
|
7451a7cddd | ||
|
|
760211ad6f | ||
|
|
3d31258704 | ||
|
|
1537fac957 | ||
|
|
d9eb370a72 | ||
|
|
032f2c83b8 | ||
|
|
126f51939f | ||
|
|
7756264e7f | ||
|
|
dc526ca038 | ||
|
|
cd3082ccf4 | ||
|
|
589ac51b2e | ||
|
|
b24a30b0c7 | ||
|
|
67356ba438 | ||
|
|
854698084d | ||
|
|
5f4d57ef26 | ||
|
|
46db720204 | ||
|
|
1c52d91c05 | ||
|
|
8b3f785733 | ||
|
|
dff926ac14 | ||
|
|
3a6a437e20 | ||
|
|
ae51b7a17d | ||
|
|
5156fc8d32 | ||
|
|
f962d9a6f2 | ||
|
|
eac8c0ee3b | ||
|
|
fa919e168c | ||
|
|
e774b05fc6 | ||
|
|
e001d2c016 | ||
|
|
fe99027641 | ||
|
|
387889a90e | ||
|
|
5ca1c56e29 | ||
|
|
578135e3a1 | ||
|
|
a2916ed1ff | ||
|
|
4f09a34bad | ||
|
|
7623946197 | ||
|
|
35e97e96ca | ||
|
|
d7ab9177aa | ||
|
|
983667ecdc | ||
|
|
657142a6a8 | ||
|
|
d489c5ffbc | ||
|
|
aebb5602e7 | ||
|
|
a645d7ec8d | ||
|
|
def151a5af | ||
|
|
b500b68ce3 | ||
|
|
d249314f67 | ||
|
|
ffc478bed5 | ||
|
|
6e5052f7e6 | ||
|
|
82ca2bc915 | ||
|
|
71206c75ca | ||
|
|
a5f5788a3f | ||
|
|
db05dfd0f2 | ||
|
|
eeeaa41838 | ||
|
|
16f3421f11 | ||
|
|
0b84fff093 | ||
|
|
b863d69450 | ||
|
|
039960baa0 | ||
|
|
1b1eb7d0ee | ||
|
|
baf0c9812c | ||
|
|
b5d37f3f6b | ||
|
|
5f9056d966 | ||
|
|
78f3369066 | ||
|
|
067724a49a | ||
|
|
f7a5d8bce5 | ||
|
|
aad6223d1e | ||
|
|
8b53a0a8b4 | ||
|
|
d7bedcdd33 | ||
|
|
bc1756c9b7 | ||
|
|
9c398f6103 | ||
|
|
ed5c3d3f6c | ||
|
|
665e803d70 | ||
|
|
34b95bb36c | ||
|
|
5722d594ad | ||
|
|
6488899edb | ||
|
|
a7595a8fe3 | ||
|
|
ff7c067f09 | ||
|
|
b19f0de785 | ||
|
|
53c74c5509 | ||
|
|
9b205e2f53 | ||
|
|
81eada963e | ||
|
|
1ceae44e69 | ||
|
|
17709f53c0 | ||
|
|
97b50c3514 | ||
|
|
b246baf455 | ||
|
|
43e577d25e | ||
|
|
2b0db54eb9 | ||
|
|
bb74ff43d9 | ||
|
|
cef38ff7b8 | ||
|
|
1b58ee35d4 | ||
|
|
85cd7056b7 | ||
|
|
600add80b9 | ||
|
|
03471cc5e2 | ||
|
|
d4ff81017d | ||
|
|
0e4d0b7eea | ||
|
|
2020366406 | ||
|
|
217ad59c44 | ||
|
|
dcd3347bce | ||
|
|
43beb4b740 | ||
|
|
95c0b775da | ||
|
|
e83b25bc62 | ||
|
|
a1b7a452c8 | ||
|
|
d87353f5bc | ||
|
|
f75213cb9a | ||
|
|
f51ee0c0a8 | ||
|
|
eb7343b4c6 | ||
|
|
c02cb19078 | ||
|
|
8737b872dc | ||
|
|
c0277dd8ae | ||
|
|
55e2106e59 | ||
|
|
f7c88fcce4 | ||
|
|
4002411a0d | ||
|
|
de3dd398e8 | ||
|
|
385b574b36 | ||
|
|
17c6377773 | ||
|
|
7032ba95c8 | ||
|
|
6df72fb904 | ||
|
|
26fbe42e77 | ||
|
|
af37ce7b34 | ||
|
|
a1747fd7e4 | ||
|
|
3c04d4a947 | ||
|
|
f47f0aff4c | ||
|
|
0b2fbf7462 | ||
|
|
1513c4658d | ||
|
|
8df78d1873 | ||
|
|
d7f65a2abc | ||
|
|
1938f5a267 | ||
|
|
4cfe930043 | ||
|
|
694d761a53 | ||
|
|
662a2ac8b8 | ||
|
|
92197d6033 | ||
|
|
651c05b3eb | ||
|
|
fb94d7ca92 | ||
|
|
b8f6642154 | ||
|
|
50463c67e6 | ||
|
|
b0ca3f823a | ||
|
|
717e4e5457 | ||
|
|
60822c80a8 | ||
|
|
87dc9e98a7 | ||
|
|
1939dc8550 | ||
|
|
ea52a3d164 | ||
|
|
13d5e80d7c | ||
|
|
7906d77be7 | ||
|
|
6422754e88 | ||
|
|
a19f91597f | ||
|
|
2bf7451fbe | ||
|
|
c2556bbc0b | ||
|
|
744fcff54d | ||
|
|
facdbec1ab | ||
|
|
a95cc4bc4e | ||
|
|
99f174264f | ||
|
|
9a95a957c0 | ||
|
|
25015a33a3 | ||
|
|
fbbc6c6c31 | ||
|
|
09467fac9a | ||
|
|
0573fb38ba | ||
|
|
a911e86b29 | ||
|
|
0615d16e65 | ||
|
|
ecc82e7085 | ||
|
|
0b170e6407 | ||
|
|
955b65f8e4 | ||
|
|
42201df814 | ||
|
|
766976a049 | ||
|
|
4a545b92ce | ||
|
|
99c98b7443 | ||
|
|
17ff60e17e | ||
|
|
1e84530e41 | ||
|
|
8e3b5d79e3 | ||
|
|
5b7e1b396b | ||
|
|
494db979cd | ||
|
|
dbf1f286aa | ||
|
|
3c89d98523 | ||
|
|
6489d44386 | ||
|
|
e2a97da53e | ||
|
|
ee67c9e720 | ||
|
|
b6dd0b6638 | ||
|
|
4e56e53a05 | ||
|
|
b6a8fe54a0 | ||
|
|
974408a4b3 | ||
|
|
dd0d164a62 | ||
|
|
e5f07b0b18 | ||
|
|
d30e1a1f35 | ||
|
|
576a0dba33 | ||
|
|
bd2b8567af | ||
|
|
a2f7b352a2 | ||
|
|
58f646cb55 | ||
|
|
184d206ee0 | ||
|
|
5d71b7cf88 | ||
|
|
7f191a0b93 | ||
|
|
e6dee5ca09 | ||
|
|
fdf5ce970d | ||
|
|
10fa7e0a2a | ||
|
|
a5c34cbbda | ||
|
|
2fe7b18025 | ||
|
|
ad784b03d0 | ||
|
|
b402dfe02d | ||
|
|
fc2d77671d | ||
|
|
29e63296ac | ||
|
|
0081e0ec39 | ||
|
|
b6e1a4804c | ||
|
|
2fde8ebe27 | ||
|
|
e7084f288a | ||
|
|
47a8b78cbd | ||
|
|
36f525f803 | ||
|
|
3772ca7a92 | ||
|
|
7f806ecb8d | ||
|
|
3629c6461b | ||
|
|
54da0b9c9f | ||
|
|
0b9f8c8976 | ||
|
|
8275e35889 | ||
|
|
b7ef4ed7b6 | ||
|
|
a18e05534d | ||
|
|
f7636dec56 | ||
|
|
0204888265 | ||
|
|
4b56a573c6 | ||
|
|
d041986fb7 | ||
|
|
82da4e54e9 | ||
|
|
c145547454 | ||
|
|
2aa607e408 | ||
|
|
584cdbb740 | ||
|
|
d1d0e5115a | ||
|
|
9046bcdb38 | ||
|
|
b39bb1a36e | ||
|
|
2c3346573d | ||
|
|
5f8afd1b3c | ||
|
|
4c37be6272 | ||
|
|
7744b8bd9a | ||
|
|
5078c25bc2 | ||
|
|
9756c1c3b8 | ||
|
|
d297c15b88 | ||
|
|
9e92ed7081 | ||
|
|
892e8d2413 | ||
|
|
467ca38ea1 | ||
|
|
ece54843b2 | ||
|
|
aceac5c248 | ||
|
|
53f6420419 | ||
|
|
4d74a1c071 | ||
|
|
3c71da468f | ||
|
|
6bbfbc5850 | ||
|
|
5144593a0a | ||
|
|
300dca913e | ||
|
|
8e55efbeb5 | ||
|
|
0fe9a2a19a | ||
|
|
dbbdde4e95 | ||
|
|
4708139f5c | ||
|
|
c5cadb9a73 | ||
|
|
c1c7ee5a10 | ||
|
|
78182472fc | ||
|
|
d8b342d0e0 | ||
|
|
e85a03cfef | ||
|
|
7fe8d5b01b | ||
|
|
6d2d71adae | ||
|
|
2ec05236b0 | ||
|
|
358b383e6b | ||
|
|
c79b20ce51 | ||
|
|
1ce6238d51 | ||
|
|
8808bf9510 | ||
|
|
d86a721db5 | ||
|
|
0d6c152649 | ||
|
|
a04a37fd5f | ||
|
|
b975115e69 | ||
|
|
f68ae33cad | ||
|
|
60c20e2c82 | ||
|
|
3665e2fc13 | ||
|
|
d170214cc8 | ||
|
|
e26ab5ff81 | ||
|
|
cf2b97737e | ||
|
|
7d45445b64 | ||
|
|
17f7425828 | ||
|
|
5f1d2e78b5 | ||
|
|
5007bb029d | ||
|
|
8e35995455 | ||
|
|
cdcc9d4eb2 | ||
|
|
d645040d3b | ||
|
|
dcfd10bbc7 | ||
|
|
4f22088086 | ||
|
|
75e4c13bf4 | ||
|
|
1934df6517 | ||
|
|
abc139ce05 | ||
|
|
87f67ae1eb | ||
|
|
c77c67361a | ||
|
|
308ed7d598 | ||
|
|
caf9ce943f | ||
|
|
6e504a7f65 | ||
|
|
923237a521 | ||
|
|
2411804868 | ||
|
|
4eac654d13 | ||
|
|
94a6f5ff89 | ||
|
|
981841583a | ||
|
|
871b4c3808 | ||
|
|
0b64e44e3b | ||
|
|
df1f1c5e09 | ||
|
|
6752212969 | ||
|
|
ddf52dd4ec | ||
|
|
d10fd3c7c5 | ||
|
|
92d8aa6004 | ||
|
|
369bf2046c | ||
|
|
46b00c59f8 | ||
|
|
f96af0d543 | ||
|
|
1641565a33 | ||
|
|
1325da964a | ||
|
|
903e0910cd | ||
|
|
2f3fa8d5ae | ||
|
|
a887b4d1cc | ||
|
|
c24dac0253 | ||
|
|
a4c4126e22 | ||
|
|
4795c114a1 | ||
|
|
9cbe675b06 | ||
|
|
b809631b9d | ||
|
|
518ac2a4ad | ||
|
|
4a62065e81 | ||
|
|
be0a0ab889 | ||
|
|
d940550b34 | ||
|
|
86e9221b4f | ||
|
|
edf2e256bd | ||
|
|
0c8136ab3d | ||
|
|
771b189b77 | ||
|
|
38ee1f0f58 | ||
|
|
5a65d94c30 | ||
|
|
8767554c67 | ||
|
|
41bd0aec7e | ||
|
|
ced13efd5a | ||
|
|
4fe4a9bcf7 | ||
|
|
0a553896d7 | ||
|
|
d05cabd833 | ||
|
|
748cd38d00 | ||
|
|
9f7345a2ea | ||
|
|
ba8ccbf4f4 | ||
|
|
36f7724f36 | ||
|
|
924eb59bb2 | ||
|
|
4517d6ca23 | ||
|
|
da19921469 | ||
|
|
6850d256d6 | ||
|
|
8c409ff8fc | ||
|
|
154a07aca7 | ||
|
|
552a3f4c09 | ||
|
|
b989e3dff5 | ||
|
|
23e84322df | ||
|
|
18ce97ee4d | ||
|
|
4e91222861 | ||
|
|
7083e66e0e | ||
|
|
4da462d9ab | ||
|
|
1a5e0852a7 | ||
|
|
9ed7ab3c44 | ||
|
|
ce3ce08bc4 | ||
|
|
d03e768459 | ||
|
|
23ccdea1b4 | ||
|
|
8168911488 | ||
|
|
d5d0afd06b | ||
|
|
40bd40637c | ||
|
|
3793bd6e4f | ||
|
|
7659b6ec3e | ||
|
|
944033c683 | ||
|
|
e234ac4d65 | ||
|
|
eda81129d9 | ||
|
|
27356f4f5d | ||
|
|
bc5d447e9d | ||
|
|
f54b96c4e4 | ||
|
|
1f24e6b0a8 | ||
|
|
41d9f2fc9a | ||
|
|
8808cf0904 | ||
|
|
bb56e9c59c | ||
|
|
e078e0b9c4 | ||
|
|
a67c12cddb | ||
|
|
b6c966197a | ||
|
|
11ad42bf34 | ||
|
|
c559b6ad5f |
269
.github/ci-validateTranslations.js
vendored
Normal file
269
.github/ci-validateTranslations.js
vendored
Normal 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
43
.github/workflows/static.yml
vendored
Normal 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
|
||||
29
.github/workflows/validate_translations.yml
vendored
Normal file
29
.github/workflows/validate_translations.yml
vendored
Normal 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
12
.gitignore
vendored
@@ -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
11
.prettierrc
Normal 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
156
360.html
Normal 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<b=80&bitrate=12000&cleanoutput&label=360_viewer&view="+view+"&password="+password;
|
||||
} else {
|
||||
document.getElementById("iframe").src = "./?speakermuted&sendframes&manual&scale=100<b=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>
|
||||
@@ -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.
|
||||
|
||||
|
||||
11
LICENCE.md
11
LICENCE.md
@@ -1,15 +1,14 @@
|
||||
The OBS.Ninja source repository is governed by the GNU AFFERO GENERAL PUBLIC LICENSE. (AGPL-3.0)
|
||||
That AGPL-3.0 licence can be found here: [AGPLv3.md](https://github.com/steveseguin/obsninja/blob/master/AGPLv3.md)
|
||||
The VDO.Ninja source repository is governed by the GNU AFFERO GENERAL PUBLIC LICENSE. (AGPL-3.0)
|
||||
That AGPL-3.0 licence can be found here: [AGPLv3.md](https://github.com/steveseguin/vdo.ninja/blob/master/AGPLv3.md)
|
||||
|
||||
In essence, OBS.Ninja is open-source and free to use, both for commercial and non-commercial use.
|
||||
In essence, VDO.Ninja is open-source and free to use, both for commercial and non-commercial use.
|
||||
Modifications of AGPL-3.0 licenced code must be made publicly accessible. Please refer to that licence.
|
||||
|
||||
Some individual source files may contain different licencing term and perhaps different copyright holders.
|
||||
Such licencing and copyright information will be contained in the file's header and be limited to those files.
|
||||
If no such header is present in a file, the default AGPL-3.0 licence applies.
|
||||
Alternative licencing options can be made available on request, if AGPL-3.0 is not appropriate.
|
||||
|
||||
Unless stated otherwise, all code is copyright 2020 Stephen Seguin. All rights reserved.
|
||||
Contributors to the OBS.Ninja project must first agree to the Contributor License Agreement (CLA).
|
||||
Unless stated otherwise, all code is copyright 2021 Stephen Seguin. All rights reserved.
|
||||
Contributors to the VDO.Ninja project must first agree to the Contributor License Agreement (CLA).
|
||||
|
||||
Thank you for your understanding.
|
||||
|
||||
119
README.md
119
README.md
@@ -1,72 +1,110 @@
|
||||
|
||||
#### ⚠ Notice! We've rebranded from OBS.Ninja to VDO.Ninja - still all else the same though ✨
|
||||
<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" />
|
||||
[](https://github.com/steveseguin/vdoninja)
|
||||
[](https://github.com/steveseguin/vdoninja/fork)
|
||||
[](https://github.com/steveseguin/vdoninja/releases)
|
||||
[](https://discord.vdo.ninja)
|
||||
[](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)
|
||||
@@ -75,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.
|
||||
|
||||
@@ -82,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
848
auth-client.js
Normal 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
342
auth-styles.css
Normal 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
188
base64.html
Normal 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
550
browser.html
Normal 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
578
browsercheck.html
Normal 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
349
changepassword.html
Normal 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
1539
check.html
Normal file
File diff suppressed because it is too large
Load Diff
791
clipboard.html
Normal file
791
clipboard.html
Normal 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
147
cloud.html
Normal 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>&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 isn’t 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">
|
||||
Dropbox’s generated tokens expire quickly (typically ~4 hours) and do not refresh. Prefer the OAuth link unless
|
||||
you’re temporarily sidestepping browser restrictions.
|
||||
</p>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
264
cloudflare.html
Normal file
264
cloudflare.html
Normal 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
781
codeccomparison.html
Normal 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>
|
||||
1781
codecs.html
Normal file
1781
codecs.html
Normal file
File diff suppressed because it is too large
Load Diff
2417
comms.html
Normal file
2417
comms.html
Normal file
File diff suppressed because one or more lines are too long
162
confirm.html
Normal file
162
confirm.html
Normal 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="© 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>
|
||||
71
convert.html
71
convert.html
@@ -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();
|
||||
|
||||
69
core/audio/meter.worklet.js
Normal file
69
core/audio/meter.worklet.js
Normal 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
83
core/audio/meters.js
Normal 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
14
core/events/event-bus.js
Normal 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
19
core/events/level-bus.js
Normal 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
7
core/index.js
Normal 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';
|
||||
75
core/legacy/meter-bridge.js
Normal file
75
core/legacy/meter-bridge.js
Normal 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();
|
||||
};
|
||||
}
|
||||
58
core/legacy/session-bridge.js
Normal file
58
core/legacy/session-bridge.js
Normal 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
3
core/recording/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { MultiTrackRecorder } from './multitrack-recorder.js';
|
||||
export { TrackRecorder } from './track-recorder.js';
|
||||
export { convertBlobToWav, audioBufferToWav } from './wav-encoder.js';
|
||||
440
core/recording/multitrack-recorder.js
Normal file
440
core/recording/multitrack-recorder.js
Normal 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}`;
|
||||
}
|
||||
}
|
||||
134
core/recording/track-recorder.js
Normal file
134
core/recording/track-recorder.js
Normal 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;
|
||||
}
|
||||
}
|
||||
321
core/recording/wav-encoder.js
Normal file
321
core/recording/wav-encoder.js
Normal 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' });
|
||||
}
|
||||
242
core/uploads/cloud-storage.js
Normal file
242
core/uploads/cloud-storage.js
Normal 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
1
core/uploads/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { CloudUploadCoordinator } from './cloud-storage.js';
|
||||
257
devices.css
257
devices.css
@@ -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;
|
||||
}
|
||||
117
devices.html
117
devices.html
@@ -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
838
director-messenger.html
Normal 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
151
dock.html
@@ -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
513
docs.html
Normal 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
296
dropbox-auth.html
Normal 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>
|
||||
413
electron.html
413
electron.html
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -548,6 +851,7 @@ function addUrlToHistory(url){
|
||||
|
||||
function modURL(){
|
||||
var url = document.getElementById('changeText').value;
|
||||
url = url.trim();
|
||||
if (url.startsWith("obs.ninja")){
|
||||
url = "https://"+url;
|
||||
} else if (url.startsWith("youtube.com")){
|
||||
@@ -561,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;
|
||||
}
|
||||
@@ -579,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
394
electroncapture.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -23,6 +23,7 @@
|
||||
#container {
|
||||
display:block;
|
||||
padding:0px;
|
||||
padding:0px;
|
||||
}
|
||||
input{
|
||||
padding:5px;
|
||||
@@ -39,12 +40,15 @@
|
||||
|
||||
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=";
|
||||
iframe.src = "../?dir=teststeve123&password=1234";
|
||||
iframeContainer.appendChild(iframe);
|
||||
document.getElementById("container").appendChild(iframeContainer);
|
||||
|
||||
var listOfStreamIDs = [
|
||||
"1234_pov",
|
||||
@@ -54,37 +58,35 @@
|
||||
"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("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 = "SHOW "+listOfStreamIDs[i];
|
||||
button.innerHTML = "TOGGLE "+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"}}}, '*');
|
||||
iframe.contentWindow.postMessage({
|
||||
action: "addScene",
|
||||
value: "1",
|
||||
target: this.dataset.sid
|
||||
}, '*');
|
||||
iframe.contentWindow.postMessage({
|
||||
action: "mic",
|
||||
value: true,
|
||||
target: this.dataset.sid
|
||||
}, '*');
|
||||
|
||||
}; // 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
|
||||
|
||||
@@ -116,7 +118,6 @@
|
||||
outputWindow.style.border="1px dotted black";
|
||||
iframeContainer.appendChild(outputWindow);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -125,7 +126,20 @@
|
||||
|
||||
|
||||
<div id="container">
|
||||
<button onclick="loadIframe();">CONNECT</button>
|
||||
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
@@ -13,13 +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");
|
||||
|
||||
generateHash(password + location.hostname, 4).then(function(hash) { // million to one error.
|
||||
password = password.trim();
|
||||
password = encodeURIComponent(password);
|
||||
|
||||
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>
|
||||
@@ -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
186
examples/chatoverlay.html
Normal 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
131
examples/control.html
Normal 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
112
examples/custom_labels.html
Normal 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>
|
||||
149
examples/custom_overlay.html
Normal file
149
examples/custom_overlay.html
Normal 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>
|
||||
58
examples/custom_video_switcher.html
Normal file
58
examples/custom_video_switcher.html
Normal 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>
|
||||
489
examples/datachannel-pubsub.html
Normal file
489
examples/datachannel-pubsub.html
Normal 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><iframe src="https://vdo.ninja/?room=myroom&push=cam1&datachannel=true"
|
||||
style="display:none"></iframe>
|
||||
|
||||
// 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
531
examples/dataiframes.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
634
examples/dynamic-viewer.html
Normal file
634
examples/dynamic-viewer.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
696
examples/gamecontroller.html
Normal file
696
examples/gamecontroller.html
Normal 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>
|
||||
1
examples/github.svg
Normal file
1
examples/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><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>
|
||||
|
After Width: | Height: | Size: 814 B |
964
examples/googleai.html
Normal file
964
examples/googleai.html
Normal 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
45
examples/grid.html
Normal 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
290
examples/httpwssapi.md
Normal 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.
|
||||
129
examples/iframe.inbound-stats.html
Normal file
129
examples/iframe.inbound-stats.html
Normal 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>
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
434
examples/iframe_api_enhanced.md
Normal file
434
examples/iframe_api_enhanced.md
Normal 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
1111
examples/iframeapi.md
Normal file
File diff suppressed because it is too large
Load Diff
541
examples/index.html
Normal file
541
examples/index.html
Normal file
@@ -0,0 +1,541 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 64px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0 0 0.6em;
|
||||
font-size: clamp(2rem, 2.8vw, 2.6rem);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 0 0 0.8em;
|
||||
color: var(--text-muted);
|
||||
max-width: 70ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#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: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.example-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 22px 42px -28px rgba(11, 15, 25, 0.85);
|
||||
}
|
||||
|
||||
.example-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.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>
|
||||
<div id="header">
|
||||
<a id="logoname" href="../">
|
||||
<span data-translate="logo-header">
|
||||
<font id="qos">V</font>DO.Ninja
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<main class="container">
|
||||
<section class="page-header">
|
||||
<h1>Examples & 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const groupsRoot = document.getElementById("example-groups");
|
||||
|
||||
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
363
examples/kick.html
Normal 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
165
examples/labelonly.html
Normal 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}¬mobile&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>
|
||||
@@ -1523,10 +1523,6 @@ iframe {
|
||||
|
||||
.popup .menu { margin: 2px; }
|
||||
|
||||
.my-float {
|
||||
margin-top: 7px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.toggleSize {
|
||||
font-size: 32px;
|
||||
color: white;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
451
examples/mixer.html
Normal file
451
examples/mixer.html
Normal file
@@ -0,0 +1,451 @@
|
||||
<html>
|
||||
<head>
|
||||
<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>
|
||||
body{
|
||||
padding:0;
|
||||
margin:0;
|
||||
background-color: #0000;
|
||||
}
|
||||
iframe {
|
||||
border:0;
|
||||
padding:0;
|
||||
display:block;
|
||||
width:1280px;
|
||||
height:720px;
|
||||
background-color: #111;
|
||||
}
|
||||
#viewlink {
|
||||
width:400px;
|
||||
}
|
||||
#container {
|
||||
display:block;
|
||||
padding:0px;
|
||||
}
|
||||
input{
|
||||
padding:5px;
|
||||
margin:5px;
|
||||
}
|
||||
button{
|
||||
padding:5px;
|
||||
margin:5px;
|
||||
}
|
||||
canvas{
|
||||
padding:10px;
|
||||
cursor:pointer;
|
||||
}
|
||||
.thing {
|
||||
width: 100px;
|
||||
height: 2em;
|
||||
padding: 0.5em;
|
||||
margin: 0.5em;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
cursor: grab;
|
||||
}
|
||||
.empty {
|
||||
width: 100px;
|
||||
height: 2em;
|
||||
padding: 0.5em;
|
||||
margin: 0.5em;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
user-select: none;
|
||||
}
|
||||
.col {
|
||||
width: 130px;
|
||||
height: 450px;
|
||||
padding: 1em;
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
float: left;
|
||||
}
|
||||
|
||||
</style>
|
||||
<script>
|
||||
|
||||
function allowDrop(ev) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
function swapNodes(n1, n2) {
|
||||
var p1 = n1.parentNode;
|
||||
var p2 = n2.parentNode;
|
||||
var i1, i2;
|
||||
|
||||
if ( !p1 || !p2 || p1.isEqualNode(n2) || p2.isEqualNode(n1) ) return;
|
||||
|
||||
for (var i = 0; i < p1.children.length; i++) {
|
||||
if (p1.children[i].isEqualNode(n1)) {
|
||||
i1 = i;
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < p2.children.length; i++) {
|
||||
if (p2.children[i].isEqualNode(n2)) {
|
||||
i2 = i;
|
||||
}
|
||||
}
|
||||
|
||||
if ( p1.isEqualNode(p2) && i1 < i2 ) {
|
||||
i2++;
|
||||
}
|
||||
p1.insertBefore(n2, p1.children[i1]);
|
||||
p2.insertBefore(n1, p2.children[i2]);
|
||||
}
|
||||
|
||||
function drag(ev) {
|
||||
ev.dataTransfer.setData("text", ev.target.id);
|
||||
}
|
||||
|
||||
function drop(ev) {
|
||||
ev.preventDefault();
|
||||
var data = ev.dataTransfer.getData("text");
|
||||
var origThing = document.getElementById(data);
|
||||
console.log(origThing);
|
||||
console.log(data);
|
||||
console.log(ev);
|
||||
//var newThing = origThing.cloneNode(true);
|
||||
if (ev.target.classList.contains("thing")){
|
||||
//ev.target.parentNode.insertBefore(origThing, ev.target.nextSibling);
|
||||
//elem.parentNode.insertBefore(elem, elem.parentNode.firstChild);
|
||||
swapNodes( ev.target, origThing);
|
||||
var slot = origThing.dataset.slot;
|
||||
origThing.dataset.slot = ev.target.dataset.slot;
|
||||
ev.target.dataset.slot = slot;
|
||||
|
||||
} else if (ev.target.classList.contains("empty")){
|
||||
ev.target.parentNode.insertBefore(origThing, ev.target.nextSibling);
|
||||
origThing.dataset.slot = ev.target.dataset.slot;
|
||||
ev.target.style.display = "none";
|
||||
}
|
||||
origThing.style.backgroundColor = ev.target.style.backgroundColor;
|
||||
|
||||
|
||||
}
|
||||
|
||||
function dropRemove(ev) {
|
||||
ev.preventDefault();
|
||||
var data = ev.dataTransfer.getData("text");
|
||||
var origThing = document.getElementById(data);
|
||||
if (origThing.dataset.slot){
|
||||
document.querySelector(".empty[data-slot='"+origThing.dataset.slot+"']").style.display = "block";
|
||||
delete origThing.dataset.slot;
|
||||
}
|
||||
origThing.style.backgroundColor = "#000";
|
||||
if (ev.target.classList.contains("thing")){
|
||||
ev.target.parentNode.insertBefore(origThing, ev.target.nextSibling);
|
||||
} else {
|
||||
ev.target.appendChild(origThing);
|
||||
}
|
||||
document.getElementById("col2").appendChild(document.getElementById("delete"));
|
||||
}
|
||||
|
||||
var streamIDs = [];
|
||||
|
||||
function updateList(){
|
||||
//<div id="col2" ondrop="dropRemove(event)" ondragover="allowDrop(event)">
|
||||
// <div class="thing" draggable="true" ondragstart="drag(event)" id="thing4">THING 4</div>
|
||||
// <div class="thing" draggable="true" ondragstart="drag(event)" id="thing1">THING 1</div>
|
||||
//</div>
|
||||
for (var i=0;i<streamIDs.length;i++){
|
||||
if (!document.getElementById("sid_"+streamIDs[i])){
|
||||
var thing = document.createElement("div");
|
||||
thing.draggable = true;
|
||||
thing.classList.add("thing");
|
||||
thing.addEventListener("dragstart", drag);
|
||||
thing.dataset.sid = streamIDs[i];
|
||||
thing.id = "sid_"+streamIDs[i];
|
||||
thing.innerText = streamIDs[i];
|
||||
document.getElementById("col2").appendChild(thing);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("col2").appendChild(document.getElementById("delete"));
|
||||
}
|
||||
|
||||
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 promptRoom = prompt("Enter a room name to use");
|
||||
if (promptRoom){
|
||||
var iframesrc = "https://vdo.ninja/?transparent&cleanoutput&manual&scene=manualtestscene&room="+promptRoom;
|
||||
} else {
|
||||
promptRoom = "testroom123312";
|
||||
var iframesrc = "https://vdo.ninja/?transparent&cleanoutput&manual&scene=manualtestscene&room="+promptRoom;
|
||||
}
|
||||
|
||||
function activate(){
|
||||
console.log(this.dataset.layout);
|
||||
var layout = JSON.parse(this.dataset.layout);
|
||||
|
||||
iframe.contentWindow.postMessage({"target":"*", "remove":true}, '*');
|
||||
|
||||
|
||||
|
||||
for (var i=0;i<layout.length;i++){
|
||||
|
||||
var stream = document.querySelector(".thing[data-slot='"+(i+1)+"'");
|
||||
if (!stream){continue;}
|
||||
|
||||
var x = layout[i].x|| 0;
|
||||
var y = layout[i].y || 0;
|
||||
var w = layout[i].w || 0;
|
||||
var h = layout[i].h || 0;
|
||||
var cover = layout[i].cover || false;
|
||||
|
||||
if (!(w && h)){continue;}
|
||||
|
||||
x = x + "%";
|
||||
y = y + "%";
|
||||
w = w + "%";
|
||||
h = h + "%";
|
||||
|
||||
if (cover){
|
||||
cover = "object-fit:cover;";
|
||||
} else {
|
||||
cover = "";
|
||||
}
|
||||
|
||||
iframe.contentWindow.postMessage({"target":stream.dataset.sid, "add":true, "settings":{"style": "width:"+w+";height:"+h+";position:absolute;left:"+x+";top:"+y+";display:block;"+cover}}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "Refresh list";
|
||||
button.onclick = function(){iframe.contentWindow.postMessage({"getStreamIDs":true}, '*');};
|
||||
button.style.display = "block";
|
||||
document.getElementById("sources").appendChild(button);
|
||||
|
||||
var a = document.createElement("a");
|
||||
a.innerHTML = "Invite Guest Link";
|
||||
a.href = "https://vdo.ninja/?room="+promptRoom+"&broadcast";
|
||||
a.target = "_blank";
|
||||
document.getElementById("sources").appendChild(a);
|
||||
|
||||
var colors = [
|
||||
"#00AAAA",
|
||||
"#FF0000",
|
||||
"#0000FF",
|
||||
"#AA00AA",
|
||||
"#00FF00",
|
||||
"#AAAA00"
|
||||
];
|
||||
|
||||
|
||||
var slots = document.getElementById("col1").children;
|
||||
for (var i=0;i<slots.length;i++){
|
||||
slots[i].style.backgroundColor = colors[i];
|
||||
}
|
||||
|
||||
|
||||
function drawLayout(layout){
|
||||
for (var i=0;i<layout.length;i++){
|
||||
layout[i].i = i;
|
||||
}
|
||||
|
||||
function compare( a, b ) { // sorts layout based on z-index.
|
||||
var aa = a.z || 0;
|
||||
var bb = b.z || 0;
|
||||
if ( aa > bb ){
|
||||
return 1;
|
||||
}
|
||||
if ( aa < bb ){
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
layout.sort(compare);
|
||||
|
||||
|
||||
|
||||
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width="80";
|
||||
canvas.height="45";
|
||||
var ctx = canvas.getContext('2d');
|
||||
document.getElementById("container").appendChild(canvas);
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, 80, 45);
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fill();
|
||||
|
||||
for (var i=0;i<layout.length;i++){
|
||||
|
||||
ctx.fillStyle = colors[layout[i].i];
|
||||
ctx.lineWidth = 3;
|
||||
var x = layout[i].x*0.8 || 0;
|
||||
var y = layout[i].y*0.45 || 0;
|
||||
var w = layout[i].w*0.8 || 0;
|
||||
var h = layout[i].h*0.45 || 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, w, h);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
canvas.dataset.layout = JSON.stringify(layout);
|
||||
canvas.onclick = activate;
|
||||
}
|
||||
|
||||
var data = [
|
||||
{x:0, y:0, w:100, h:100}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{x:0, y:0, w:0, h:0},
|
||||
{x:0, y:0, w:100, h:100, cover:true}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{x:0, y:25, w:50, h:50, cover:true},
|
||||
{x:50, y:25, w:50, h:50, cover:true}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
|
||||
var data = [
|
||||
{x:70, y:70, w:30, h:30, z:1, cover:false},
|
||||
{x:0, y:0, w:100, h:100,z:0, cover:true}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{x:0, y:0, w:100, h:100,z:0, cover:true},
|
||||
{x:70, y:70, w:30, h:30, z:1, cover:false}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{x:0, y:0, w:20, h:20, z:1, cover:false},
|
||||
{x:0, y:0, w:100, h:100,z:0, cover:true}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{x:0, y:0, w:50, h:50},
|
||||
{x:50, y:0, w:50, h:50},
|
||||
{x:0, y:50, w:50, h:50},
|
||||
{x:50, y:50, w:50, h:50}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{x:0, y:16.667, w:66.667, h:66.667},
|
||||
{x:66.667, y:0, w:33.333, h:33.333},
|
||||
{x:66.667, y:33.333, w:33.333, h:33.333},
|
||||
{x:66.667, y:66.667, w:33.333, h:33.333}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{x:66.667, y:0, w:33.333, h:33.333},
|
||||
{x:0, y:16.667, w:66.667, h:66.667},
|
||||
{x:66.667, y:33.333, w:33.333, h:33.333},
|
||||
{x:66.667, y:66.667, w:33.333, h:33.333}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{x:0, y:0, w:0, h:0},
|
||||
{},
|
||||
{x:0, y:0, w:100, h:100}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{x:0, y:0, w:100, h:100}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{},
|
||||
{},
|
||||
{x:70, y:70, w:30, h:30, z:1, cover:false},
|
||||
{x:0, y:0, w:100, h:100,z:0, cover:true}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
var data = [
|
||||
{},
|
||||
{},
|
||||
{x:0, y:25, w:50, h:50, cover:true},
|
||||
{x:50, y:25, w:50, h:50, cover:true}
|
||||
];
|
||||
drawLayout(data);
|
||||
|
||||
|
||||
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 (e.data.action === "new-view-connection"){
|
||||
iframe.contentWindow.postMessage({"getStreamIDs":true}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ("streamIDs" in e.data){
|
||||
streamIDs = [];
|
||||
for (var key in e.data.streamIDs){
|
||||
streamIDs.push(key);
|
||||
}
|
||||
updateList();
|
||||
console.log(streamIDs);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload="loadIframe();">
|
||||
<div class="col" id="sources">
|
||||
<div id="col2" ondrop="dropRemove(event)" ondragover="allowDrop(event)">
|
||||
<div class="thing" draggable="false" id="delete" style="background-color:rgb(96 9 9);">REMOVE</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col" id="col1" ondrop="drop(event)" ondragover="allowDrop(event)">
|
||||
<div class="empty" data-slot="1">SLOT 1</div>
|
||||
<div class="empty" data-slot="2">SLOT 2</div>
|
||||
<div class="empty" data-slot="3">SLOT 3</div>
|
||||
<div class="empty" data-slot="4">SLOT 4</div>
|
||||
</div>
|
||||
<div id="container">
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
147
examples/mobiledirector.css
Normal file
147
examples/mobiledirector.css
Normal file
@@ -0,0 +1,147 @@
|
||||
body{
|
||||
zoom: 75%;
|
||||
}
|
||||
button[data-action-type='solo-chat'] {
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-action-type] {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
#controlButtons{
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-cluster='2'] {
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-action-type='solo-video'] {
|
||||
display:unset!important;
|
||||
visibility: visible;
|
||||
width:unset;
|
||||
height:unset;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
div > a.soloLink{
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
div.shift{
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
div.streamID{
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-action-type='forward'] {
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-action-type='direct-chat'] {
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-action-type='hangup'] {
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-action-type='solo-chat'] {
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-action-type='solo-chat'] {
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-cluster='1'] {
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
|
||||
button[data-action-type='recorder-local'] {
|
||||
display:none! important;
|
||||
}
|
||||
span[data-action-type='ordering'] {
|
||||
display:none! important;
|
||||
}
|
||||
button[data-action-type='open-file-share'] {
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
button[data-action-type='add-channel']{
|
||||
display:none! important;
|
||||
}
|
||||
button[data-action-type='toggle-remote-speaker']{
|
||||
display:none! important;
|
||||
}
|
||||
button[data-action-type='toggle-remote-display']{
|
||||
display:none! important;
|
||||
}
|
||||
button[data-action-type='hide-guest']{
|
||||
display:none! important;
|
||||
}
|
||||
button[data-action-type='create-timer']{
|
||||
display:none! important;
|
||||
}
|
||||
button[data-action-type='change-url']{
|
||||
display:none! important;
|
||||
}
|
||||
button[data-action-type='change-params']{
|
||||
display:none! important;
|
||||
}
|
||||
span[data-action-type='change-quality']{
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
span[data-action-type='sceneCluster2']{
|
||||
display:none! important;
|
||||
}
|
||||
span[data-action-type='sceneCluster1']{
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
.orderspan{
|
||||
display:none! important;
|
||||
}
|
||||
#roomHeader{
|
||||
display:none! important;
|
||||
}
|
||||
.directorContainer {
|
||||
display:none!important;
|
||||
}
|
||||
|
||||
.hideDropMenu{
|
||||
display:none!important;
|
||||
}
|
||||
|
||||
#header{
|
||||
display:none!important;
|
||||
}
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
button[class="pull-right"]{
|
||||
display:none! important;
|
||||
}
|
||||
|
||||
div#guestFeeds {
|
||||
padding: 1px!important;
|
||||
margin: 1px!important;
|
||||
}
|
||||
div[class="vidcon directorMargins"] {
|
||||
padding: 1px!important;
|
||||
margin: 1px!important;
|
||||
width: 260px;
|
||||
}
|
||||
button[data-action-type]{
|
||||
margin: 1px!important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
112
examples/multi.html
Normal file
112
examples/multi.html
Normal 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>
|
||||
319
examples/muteguestiframe.html
Normal file
319
examples/muteguestiframe.html
Normal 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>
|
||||
11
examples/nes.min.css
vendored
Normal file
11
examples/nes.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
357
examples/noisegate.html
Normal file
357
examples/noisegate.html
Normal 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&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">Drop‑in 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>
|
||||
@@ -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
440
examples/obsremote.html
Normal 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
146
examples/overlay.html
Normal 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
648
examples/p2pdrawing.md
Normal 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
136
examples/powerpoint.html
Normal 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
163
examples/ptz.html
Normal 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>
|
||||
@@ -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
|
||||
|
||||
495
examples/remoteapi.html
Normal file
495
examples/remoteapi.html
Normal file
@@ -0,0 +1,495 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link href="https://fonts.googleapis.com/css?family=Press+Start+2P" rel="stylesheet">
|
||||
<link href="./nes.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
html, body, pre, code, kbd, samp {
|
||||
font-family:"Press Start 2P";
|
||||
}
|
||||
|
||||
body{
|
||||
margin:1%;
|
||||
border:0;
|
||||
background-image: linear-gradient(to left, #e1c5d5, #ddc5da, #d8c6e0, #d1c7e5, #c8c9e9, #c1cded, #bad2f0, #b4d6f2, #b1ddf3, #b1e4f3, #b4eaf0, #bbf0ed);
|
||||
width:100%;
|
||||
height:100%;
|
||||
}
|
||||
|
||||
button {
|
||||
margin:10px 3px;
|
||||
}
|
||||
|
||||
button, input, optgroup, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
button:active{
|
||||
background-color:#BBB;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header"></div>
|
||||
<div id="target_self"></div>
|
||||
<div id="guest_1_container"></div>
|
||||
<script>
|
||||
function generateStreamID(){
|
||||
var text = "";
|
||||
var possible = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
||||
for (var i = 0; i < 10; i++){
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
(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);
|
||||
|
||||
window.onerror = function backupErr(errorMsg, url=false, lineNumber=false) {
|
||||
console.error(errorMsg);
|
||||
console.error(lineNumber);
|
||||
console.error("Unhandeled Error occured"); //or any message
|
||||
return false;
|
||||
};
|
||||
|
||||
window.onbeforeunload = function() {
|
||||
return "Dude, are you sure you want to leave? Think of the kittens!"; // prevents accidental page reloads.
|
||||
}
|
||||
|
||||
var WID = "testVDON";
|
||||
if (urlParams.has("api")){
|
||||
WID = urlParams.get("api");
|
||||
} else if (urlParams.has("osc")){
|
||||
WID = urlParams.get("osc");
|
||||
} else if (urlParams.has("id")){
|
||||
WID = urlParams.get("id");
|
||||
} else if (urlParams.has("ID")){
|
||||
WID = urlParams.get("ID");
|
||||
} else if (urlParams.has("wid")){
|
||||
WID = urlParams.get("wid");
|
||||
} else {
|
||||
WID = generateStreamID(10);
|
||||
|
||||
var href = window.location.href;
|
||||
var arr = href.split('?');
|
||||
var newurl;
|
||||
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");
|
||||
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;
|
||||
var failedCount = 0;
|
||||
|
||||
|
||||
|
||||
function connect(){
|
||||
clearTimeout(connecting);
|
||||
if (socket){
|
||||
if (socket.readyState === socket.OPEN){return;}
|
||||
try{
|
||||
socket.close();
|
||||
} catch(e){}
|
||||
}
|
||||
socket = new WebSocket("wss://api.vdo.ninja:443");
|
||||
|
||||
socket.onclose = function (){
|
||||
failedCount+=1;
|
||||
clearTimeout(connecting);
|
||||
connecting = setTimeout(function(){connect();},100*(failedCount-1));
|
||||
};
|
||||
|
||||
socket.onerror = function (){
|
||||
failedCount+=1;
|
||||
clearTimeout(connecting);
|
||||
connecting = setTimeout(function(){connect();},100*failedCount);
|
||||
};
|
||||
|
||||
socket.onopen = function (){
|
||||
failedCount = 0;
|
||||
try{
|
||||
socket.send(JSON.stringify({"join":WID}));
|
||||
} catch(e){
|
||||
connecting = setTimeout(function(){connect();},1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
function sendGuestCommand(target, action, value=null){
|
||||
sendMessage(JSON.stringify({"target":target, "action":action, "value":value}));
|
||||
}
|
||||
|
||||
function sendMessage(msg){
|
||||
if (socket.readyState !== socket.OPEN){
|
||||
console.log("not connected; msg didn't send");
|
||||
connect();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
socket.send(msg);
|
||||
} catch(e){
|
||||
connecting = setTimeout(function(){connect();},100);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
function log(msg){
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
function ajax(data) {
|
||||
var xhttp = new XMLHttpRequest();
|
||||
xhttp.onreadystatechange = function() {
|
||||
if (this.readyState == 4 && this.status == 200) {
|
||||
log("AJAX MESSAGE SENT SUCCESSFULL");
|
||||
}
|
||||
};
|
||||
var action = false
|
||||
if ("action" in data){
|
||||
action=data['action'];
|
||||
}
|
||||
var value = "null"
|
||||
if ("value" in data){
|
||||
value=data['value'];
|
||||
}
|
||||
var apiid = false
|
||||
if ("apiid" in data){
|
||||
apiid=data['apiid'];
|
||||
}
|
||||
var target = "null";
|
||||
if ("target" in data){
|
||||
target=data['target'];
|
||||
}
|
||||
|
||||
if (!action || !apiid){
|
||||
alert("no action or api ID provided; request won't work");
|
||||
} else {
|
||||
var URL = "https://api.vdo.ninja/"+apiid+"/"+action+"/"+target+"/"+value;
|
||||
xhttp.open("GET", URL, true);
|
||||
xhttp.send();
|
||||
}
|
||||
}
|
||||
|
||||
function loadSelfCommands(){
|
||||
var commands = {}
|
||||
commands.speaker = function(value){sendMessage(JSON.stringify({"action":"speaker","value":value}))}; // "speaker" also works in the same way
|
||||
|
||||
commands.mic = function(value){sendMessage(JSON.stringify({"action":"mic","value":value}))};
|
||||
|
||||
commands.camera = function(value){sendMessage(JSON.stringify({"action":"camera","value":value}))};
|
||||
|
||||
commands.bitrate = function(value){sendMessage(JSON.stringify({"action":"bitrate","value":value}))};
|
||||
|
||||
commands.volume = function(value){sendMessage(JSON.stringify({"action":"volume","value":value}))};
|
||||
|
||||
commands.record = function(value){sendMessage(JSON.stringify({"action":"record","value":value}))};
|
||||
|
||||
commands.sayHello = function(value){sendMessage(JSON.stringify({"action":"sendChat","value":"Hello"}))};
|
||||
|
||||
var target_self = document.getElementById("target_self");
|
||||
|
||||
setTimeout(function(){
|
||||
var hr = document.createElement("hr");
|
||||
target_self.appendChild(hr);
|
||||
var h3 = document.createElement("h3");
|
||||
h3.innerText = "These are Websocket-based requests";
|
||||
target_self.appendChild(h3);
|
||||
},0);
|
||||
|
||||
for (var k in commands) {
|
||||
setTimeout(function(k){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = k + ":<br />TRUE";
|
||||
button.onclick = function(){commands[k](true);}
|
||||
target_self.appendChild(button);
|
||||
},0,k);
|
||||
|
||||
setTimeout(function(k){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = k + ":<br />FALSE";
|
||||
button.onclick = function(){commands[k](false);}
|
||||
target_self.appendChild(button);
|
||||
},0,k);
|
||||
|
||||
if (k=="mic" || k=="camera" || k=="record" || k=="speaker"){
|
||||
setTimeout(function(k){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = k + ":<br />TOGGLE";
|
||||
button.onclick = function(){commands[k]("toggle");}
|
||||
target_self.appendChild(button);
|
||||
},0,k);
|
||||
}
|
||||
|
||||
log(k);
|
||||
} // list available commands to console
|
||||
|
||||
commands.reload = function(){sendMessage(JSON.stringify({"action":"reload","value":true}))};
|
||||
|
||||
commands.hangup = function(){sendMessage(JSON.stringify({"action":"hangup","value":true}))};
|
||||
|
||||
k = "reload";
|
||||
setTimeout(function(k){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = k + ":<br />TRUE";
|
||||
button.onclick = function(){commands[k](true);}
|
||||
target_self.appendChild(button);
|
||||
},0,k);
|
||||
|
||||
k = "hangup";
|
||||
setTimeout(function(k){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = k + ":<br />TRUE";
|
||||
button.onclick = function(){commands[k](true);}
|
||||
target_self.appendChild(button);
|
||||
},0,k);
|
||||
|
||||
setTimeout(function(){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "Rainbow<br />Puke";
|
||||
button.onclick = function(){sendMessage(JSON.stringify({"action":"forceKeyframe"}))}
|
||||
target_self.appendChild(button);
|
||||
},0);
|
||||
|
||||
|
||||
|
||||
var commands2 = {}
|
||||
commands2.speaker = function(value){ajax({"action":"speaker","value":value,"apiid":WID})}; // "speaker" also works in the same way
|
||||
commands2.mic = function(value){ajax({"action":"mic","value":value,"apiid":WID})}; // "speaker" also works in the same way
|
||||
commands2.camera = function(value){ajax({"action":"camera","value":value,"apiid":WID})}; // "speaker" also works in the same way
|
||||
commands2.bitrate = function(value){ajax({"action":"bitrate","value":value,"apiid":WID})}; // "speaker" also works in the same way
|
||||
commands2.volume = function(value){ajax({"action":"volume","value":value,"apiid":WID})}; // "speaker" also works in the same way
|
||||
commands2.record = function(value){ajax({"action":"record","value":value,"apiid":WID})}; // "speaker" also works in the same way
|
||||
|
||||
setTimeout(function(){
|
||||
var hr = document.createElement("hr");
|
||||
target_self.appendChild(hr);
|
||||
var h3 = document.createElement("h3");
|
||||
h3.innerText = "These are HTTP-based GET requests";
|
||||
target_self.appendChild(h3);
|
||||
},0);
|
||||
|
||||
for (var k in commands2) {
|
||||
|
||||
setTimeout(function(k){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = k + ":<br />TRUE";
|
||||
button.onclick = function(){commands2[k](true);}
|
||||
target_self.appendChild(button);
|
||||
},0,k);
|
||||
|
||||
setTimeout(function(k){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = k + ":<br />FALSE";
|
||||
button.onclick = function(){commands2[k](false);}
|
||||
target_self.appendChild(button);
|
||||
},0,k);
|
||||
|
||||
if (k=="mic" || k=="camera" || k=="record" || k=="speaker"){
|
||||
setTimeout(function(k){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = k + ":<br />TOGGLE";
|
||||
button.onclick = function(){commands2[k]("toggle");}
|
||||
target_self.appendChild(button);
|
||||
},0,k);
|
||||
}
|
||||
log(k);
|
||||
}
|
||||
|
||||
setTimeout(function(WID){
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "Rainbow<br />Puke"
|
||||
button.onclick = function(){ajax({"action":"forceKeyframe","apiid":WID})}
|
||||
target_self.appendChild(button);
|
||||
},0,WID);
|
||||
|
||||
|
||||
return commands;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function loadGuestCommands(guestid){
|
||||
var container = document.createElement("div");
|
||||
container.id = "guest_"+guestid+"_container";
|
||||
document.body.appendChild(container);
|
||||
|
||||
var hr = document.createElement("hr");
|
||||
container.appendChild(hr);
|
||||
var h3 = document.createElement("h3");
|
||||
h3.innerText = "These target guest "+guestid+ " (if a director)";
|
||||
container.appendChild(h3);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "transfer popup";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "forward");};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "transfer to 'room321'";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "forward", 'room321');};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "scene 1";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "addScene");}; /// SCENE 1 or specify a custom scene name as a value
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "mute in scene";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "muteScene");};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "mute everywhere";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "mic");};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "hang up";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "hangup");};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "solo chat";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "soloChat");};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "remote speaker";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "speaker");};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "remote display";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "display");};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "rainbow puke fix";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "forceKeyframe");};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "highlight";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "soloVideo");};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "scene 2";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "addScene", 2);};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "scene 3";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "addScene", 3);};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "scene 4";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "addScene", 4);};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "scene 5";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "addScene", 5);};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "scene 6";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "addScene", 6);};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = " scene 7";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "addScene", 7);};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "scene 8";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "addScene", 8);};
|
||||
container.appendChild(button);
|
||||
|
||||
var button = document.createElement("button");
|
||||
button.innerHTML = "scene 'test'";
|
||||
button.onclick = function(){sendGuestCommand(guestid, "addScene", 'test');}; // specifying a custom scene; it needs to be active for this to work..
|
||||
container.appendChild(button);
|
||||
|
||||
|
||||
var input = document.createElement("label");
|
||||
input.innerHTML = "mic volume:";
|
||||
container.appendChild(input);
|
||||
var input = document.createElement("input");
|
||||
input.type = "range";
|
||||
input.title = "volume";
|
||||
input.min = 0;
|
||||
input.max = 200;
|
||||
input.value = 100;
|
||||
input.onchange = function(){sendGuestCommand(guestid, "volume", this.value);};
|
||||
container.appendChild(input);
|
||||
|
||||
}
|
||||
|
||||
loadSelfCommands();
|
||||
loadGuestCommands(1);
|
||||
loadGuestCommands(2);
|
||||
loadGuestCommands(3);
|
||||
loadGuestCommands(4);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
94
examples/rip.html
Normal file
94
examples/rip.html
Normal 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
135
examples/rotated.html
Normal 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
3171
examples/sandbox.html
Normal file
File diff suppressed because it is too large
Load Diff
367
examples/sensoroverlay.html
Normal file
367
examples/sensoroverlay.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
312
examples/simple-iframe-replacement.html
Normal file
312
examples/simple-iframe-replacement.html
Normal 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
|
||||
<iframe id="vdo-iframe"
|
||||
src="https://vdo.ninja/?room=myroom&push=sender&view=receiver&datachannel=true"
|
||||
style="display:none"></iframe>
|
||||
|
||||
// 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
437
examples/simplelink.html
Normal 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="© 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
176
examples/slidingzoom.html
Normal 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="© 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
185
examples/socal.html
Normal 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>
|
||||
@@ -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
209
examples/switchmics.html
Normal 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
289
examples/teleprompt.html
Normal 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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user