Compare commits
625 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
770327c3fa | ||
|
|
4bdc6e3d65 | ||
|
|
4799a032e5 | ||
|
|
b2d2a6a7a5 | ||
|
|
7676106914 | ||
|
|
fe5ea31f2c | ||
|
|
e34223fc94 | ||
|
|
15f733f11c | ||
|
|
7526b18507 | ||
|
|
0af6007632 | ||
|
|
1bc3cd1d51 | ||
|
|
92bafd695d | ||
|
|
78a2ee4e85 | ||
|
|
8b9346d767 | ||
|
|
06d3bd3f93 | ||
|
|
1af7e4ef19 | ||
|
|
8e64e4120b | ||
|
|
b983559763 | ||
|
|
e62527de23 | ||
|
|
1f51f35f8e | ||
|
|
c3686417e3 | ||
|
|
746e10c025 | ||
|
|
98389fc07c | ||
|
|
aaebf93db4 | ||
|
|
8f03ecedaa | ||
|
|
db20bd8eaf | ||
|
|
12500dfb64 | ||
|
|
acc8e7923a | ||
|
|
9aa5775528 | ||
|
|
2a2d71289a | ||
|
|
ae28e34fd5 | ||
|
|
6b175e7d40 | ||
|
|
2c6d74e8ef | ||
|
|
3b832595fe | ||
|
|
bf372029fb | ||
|
|
17ce7e519c | ||
|
|
1f6a112df7 | ||
|
|
9d3e26f15a | ||
|
|
8a95895254 | ||
|
|
5d71f9e9c6 | ||
|
|
0ec6fb5a93 | ||
|
|
5d410457ce | ||
|
|
c6cd21b8ff | ||
|
|
2235b2fa82 | ||
|
|
65e0b5d6e7 | ||
|
|
ea64411570 | ||
|
|
9348c61a84 | ||
|
|
d9aa3822ee | ||
|
|
e86bd26800 | ||
|
|
6d8cec17de | ||
|
|
572f084570 | ||
|
|
4a3ca5459d | ||
|
|
e8cb6abf0a | ||
|
|
94b3bc561d | ||
|
|
5eb75d0250 | ||
|
|
b577e08053 | ||
|
|
dae4840bd6 | ||
|
|
57eb52a606 | ||
|
|
6d670bdd3f | ||
|
|
def2209dc5 | ||
|
|
763c573c7a | ||
|
|
eb61f2ac0e | ||
|
|
a9d1925686 | ||
|
|
0e10eba073 | ||
|
|
0427e1f3a6 | ||
|
|
c841c94784 | ||
|
|
e3c6049fdb | ||
|
|
829206e921 | ||
|
|
f7acdffb2a | ||
|
|
cc9ba385bf | ||
|
|
e88ddd648b | ||
|
|
dac34541d6 | ||
|
|
52fb378fd5 | ||
|
|
8fc61a1c90 | ||
|
|
04691ed0da | ||
|
|
f9d4e5e077 | ||
|
|
4ee36f459f | ||
|
|
2c0de25423 | ||
|
|
c82bdbd39d | ||
|
|
71b08304f2 | ||
|
|
3bb3cd7c44 | ||
|
|
41187ec43d | ||
|
|
cf5de49deb | ||
|
|
19f177f3ee | ||
|
|
556d0cb1c5 | ||
|
|
7c7780b17e | ||
|
|
bd358b7692 | ||
|
|
1b8f180255 | ||
|
|
0bc90cea21 | ||
|
|
10f96ab23e | ||
|
|
4f0df507f4 | ||
|
|
256753e6ff | ||
|
|
8ac42078d4 | ||
|
|
fc83e054ea | ||
|
|
62cb304971 | ||
|
|
d4efebd694 | ||
|
|
7833f6bbc4 | ||
|
|
8b773c5f59 | ||
|
|
5767d5b95d | ||
|
|
13f1ecdbe3 | ||
|
|
8ae4ac47e6 | ||
|
|
001601bc5f | ||
|
|
3757507b18 | ||
|
|
9abab7aec3 | ||
|
|
1aabd537d8 | ||
|
|
6e240b3fd4 | ||
|
|
501a07c386 | ||
|
|
0d92dee14e | ||
|
|
3a1fa25106 | ||
|
|
b167770ea6 | ||
|
|
2a8a26ac54 | ||
|
|
9748833ba9 | ||
|
|
bf49214790 | ||
|
|
2b4410a55a | ||
|
|
bfd1b2eaf0 | ||
|
|
44c3421e92 | ||
|
|
7de3d3cc41 | ||
|
|
3d7f46da65 | ||
|
|
72d902e548 | ||
|
|
955fe76c3c | ||
|
|
839bec0363 | ||
|
|
9af4ce9a6b | ||
|
|
205bf75c7e | ||
|
|
bafbf3d841 | ||
|
|
1b0833fb45 | ||
|
|
0a15db2892 | ||
|
|
63dda10380 | ||
|
|
6e651df083 | ||
|
|
3a8fe24eec | ||
|
|
918a35e091 | ||
|
|
c76abeb8c0 | ||
|
|
d389b8ab38 | ||
|
|
a59faacbd7 | ||
|
|
12f9709601 | ||
|
|
455c9c02b9 | ||
|
|
1b49c6568b | ||
|
|
30e81c7699 | ||
|
|
2dafc68301 | ||
|
|
c310222af2 | ||
|
|
b4bb9a6603 | ||
|
|
279c621d23 | ||
|
|
eb11dbdcbd | ||
|
|
8dfdb34d31 | ||
|
|
fc9a73d67f | ||
|
|
4b5b84a0fc | ||
|
|
327c95d754 | ||
|
|
6e969e5d07 | ||
|
|
ca7c872420 | ||
|
|
8af4f42c42 | ||
|
|
ffce76a9b1 | ||
|
|
fca1d9dae4 | ||
|
|
eba09430e3 | ||
|
|
6bc8de7625 | ||
|
|
8a08ed8538 | ||
|
|
56d802f741 | ||
|
|
f7b46336fd | ||
|
|
510187c2ae | ||
|
|
ff6ff8db00 | ||
|
|
014017604d | ||
|
|
8cf544d54f | ||
|
|
870dd893fc | ||
|
|
bf3b9ced74 | ||
|
|
9ecc12fb7f | ||
|
|
aafb1421c6 | ||
|
|
76c732bafb | ||
|
|
ab476c9a9c | ||
|
|
4470310814 | ||
|
|
b6ba4f45c8 | ||
|
|
84c860cfcb | ||
|
|
47c554a5ff | ||
|
|
4e46b16c13 | ||
|
|
22cdcdb468 | ||
|
|
f238b2d105 | ||
|
|
3a81799828 | ||
|
|
5dca89b61c | ||
|
|
264c035ef7 | ||
|
|
8f39f8a23e | ||
|
|
a29dbd7a71 | ||
|
|
60a8969abc | ||
|
|
9747d28514 | ||
|
|
17ccd217a9 | ||
|
|
0df6198549 | ||
|
|
74ada99f2b | ||
|
|
81d5a367da | ||
|
|
189e4bdc24 | ||
|
|
7bd30f8a16 | ||
|
|
7c6b7e4a6d | ||
|
|
fe50f76619 | ||
|
|
973a366b94 | ||
|
|
b0116deb35 | ||
|
|
511382d384 | ||
|
|
cfb3a01239 | ||
|
|
736d575ab1 | ||
|
|
34a9508483 | ||
|
|
21ed8797c2 | ||
|
|
454491572a | ||
|
|
6518a1535c | ||
|
|
0095687bf5 | ||
|
|
d466ed085a | ||
|
|
eeda7c17c5 | ||
|
|
b89deb46db | ||
|
|
951a89c6c9 | ||
|
|
d4e6c84279 | ||
|
|
9731d85ca3 | ||
|
|
98f7ba8931 | ||
|
|
24c775608e | ||
|
|
f4eab12c3f | ||
|
|
8eeddeb4bc | ||
|
|
58f1594d9e | ||
|
|
c126ac0a84 | ||
|
|
6768be710e | ||
|
|
b63aa090dc | ||
|
|
05ef8be5bc | ||
|
|
1a09249bc3 | ||
|
|
803820dfca | ||
|
|
deb111bf62 | ||
|
|
7d64c7e308 | ||
|
|
ffb7183f51 | ||
|
|
20c6737aba | ||
|
|
959fb20b61 | ||
|
|
5d14c923fa | ||
|
|
5ffa7c4465 | ||
|
|
461744da5b | ||
|
|
6df33bc58b | ||
|
|
b5ae8f56cf | ||
|
|
2e0de52520 | ||
|
|
7b1ff0efc6 | ||
|
|
4002392b7f | ||
|
|
ee4b84fc11 | ||
|
|
90a0ce4a4d | ||
|
|
80faba8234 | ||
|
|
ac0574a473 | ||
|
|
792e3430f1 | ||
|
|
9e33be0ab1 | ||
|
|
c343c008ed | ||
|
|
6405be5144 | ||
|
|
db743daae5 | ||
|
|
290a25c393 | ||
|
|
6589e134b3 | ||
|
|
a2aa5e4271 | ||
|
|
205e2eb551 | ||
|
|
53209a9da3 | ||
|
|
2a23611c5f | ||
|
|
cb71913cbe | ||
|
|
836d7c6664 | ||
|
|
4cef9f2911 | ||
|
|
0913988d53 | ||
|
|
6468f82a7f | ||
|
|
fd0fc769b1 | ||
|
|
e5b648dfc6 | ||
|
|
7701c5f097 | ||
|
|
e5eddce868 | ||
|
|
72f917a744 | ||
|
|
0b82c83d44 | ||
|
|
602654cc1d | ||
|
|
350bed53a3 | ||
|
|
840754fb59 | ||
|
|
ed46583226 | ||
|
|
93252d430e | ||
|
|
bfd09a058e | ||
|
|
b1a7543d37 | ||
|
|
39195fe8c4 | ||
|
|
ea1c66b3fc | ||
|
|
f35eb73d50 | ||
|
|
c99af4718e | ||
|
|
dbef07e334 | ||
|
|
969ad64c47 | ||
|
|
5dd5e8661b | ||
|
|
5c9265fc99 | ||
|
|
1deab08d38 | ||
|
|
3d6da99e8e | ||
|
|
2005ee4d0b | ||
|
|
c99da2ccaa | ||
|
|
4bffb6634c | ||
|
|
504aca747d | ||
|
|
2085312c34 | ||
|
|
744d38259e | ||
|
|
868739445a | ||
|
|
98d8a798ce | ||
|
|
fe31cfaa3e | ||
|
|
17d5490448 | ||
|
|
d4c415d585 | ||
|
|
cb8f7f53c2 | ||
|
|
8d93641ebe | ||
|
|
4faf30e0a1 | ||
|
|
ed1b27ede0 | ||
|
|
252443a529 | ||
|
|
86f5a1a54e | ||
|
|
0b1872fa28 | ||
|
|
9eeb8133af | ||
|
|
1eb5504029 | ||
|
|
dfe8c3eb6b | ||
|
|
2b8c1fe709 | ||
|
|
905cc527d0 | ||
|
|
95019453fd | ||
|
|
e46a7f42df | ||
|
|
15a59f445b | ||
|
|
dea951fc42 | ||
|
|
347eb2c7f0 | ||
|
|
4221883eb4 | ||
|
|
27f729250f | ||
|
|
452bbb60c4 | ||
|
|
9d4aeaedd3 | ||
|
|
558b6c1648 | ||
|
|
98e263e69a | ||
|
|
18b126e0d2 | ||
|
|
82dff65572 | ||
|
|
d60d298b8f | ||
|
|
ffbd8184b5 | ||
|
|
11cf4aeecd | ||
|
|
b0b8b56816 | ||
|
|
967e5ecb9c | ||
|
|
f0315f7f77 | ||
|
|
facb07cbb1 | ||
|
|
41910aea9c | ||
|
|
8fcfa3b97a | ||
|
|
8ebb2349dd | ||
|
|
1e487a3c2a | ||
|
|
291ea94a10 | ||
|
|
ade6c1e4a0 | ||
|
|
bde5dc14c3 | ||
|
|
0a005eb054 | ||
|
|
735851486e | ||
|
|
56ba5c705a | ||
|
|
cdab2dbc65 | ||
|
|
4284eb8f75 | ||
|
|
2707fc9053 | ||
|
|
1d4d4319e4 | ||
|
|
c5cc0ce09d | ||
|
|
fdd7dab76f | ||
|
|
7624f2da98 | ||
|
|
ef51f827dc | ||
|
|
011ab13c83 | ||
|
|
017d61815f | ||
|
|
3d4d1c8650 | ||
|
|
1479369db1 | ||
|
|
31ef283e7b | ||
|
|
6b70554e63 | ||
|
|
9a1c329434 | ||
|
|
4aaf6dee05 | ||
|
|
86f08ee891 | ||
|
|
0b85ba9f32 | ||
|
|
812ce8724d | ||
|
|
06f81ff759 | ||
|
|
2693075f9f | ||
|
|
c1713810b9 | ||
|
|
e08e5d14a2 | ||
|
|
a3d685e132 | ||
|
|
5471760278 | ||
|
|
969c784df4 | ||
|
|
85e49dea6d | ||
|
|
a497afe5cf | ||
|
|
2333171de7 | ||
|
|
04318d7580 | ||
|
|
5e6e5fce1e | ||
|
|
af2ad46958 | ||
|
|
432d7d4a56 | ||
|
|
f93685811a | ||
|
|
914d07df03 | ||
|
|
9c60f104c8 | ||
|
|
ee7e630177 | ||
|
|
ae168ae885 | ||
|
|
ad0fcaed46 | ||
|
|
304b81908d | ||
|
|
b10f8c5bed | ||
|
|
f6b9dbbbc4 | ||
|
|
59cc912378 | ||
|
|
33663bef3e | ||
|
|
e75cd45ec0 | ||
|
|
c98f3cd040 | ||
|
|
4c4caba002 | ||
|
|
45f6cc5247 | ||
|
|
69460db294 | ||
|
|
f8095fcdbf | ||
|
|
1a0a2b3658 | ||
|
|
f9141dd39c | ||
|
|
8c2d49f029 | ||
|
|
da1e120de9 | ||
|
|
457aca25ee | ||
|
|
ae73ae29c4 | ||
|
|
5abf421f11 | ||
|
|
e792532051 | ||
|
|
5c39665b6a | ||
|
|
d1c4579398 | ||
|
|
d80d8ef1f5 | ||
|
|
d49a8e772f | ||
|
|
1947a03e94 | ||
|
|
bc6ae4523f | ||
|
|
442ac9184f | ||
|
|
824f4ce3cf | ||
|
|
cc324024ba | ||
|
|
0921f89eb7 | ||
|
|
628c93bc1e | ||
|
|
25109a7ebb | ||
|
|
e6963d0307 | ||
|
|
9a2f16b29a | ||
|
|
6a17aa7c76 | ||
|
|
80c549ae77 | ||
|
|
9d4c65d1b1 | ||
|
|
2e08eea43d | ||
|
|
27914ef13b | ||
|
|
10d9678946 | ||
|
|
d8a150a60d | ||
|
|
5abc319ecf | ||
|
|
e0f02ace9e | ||
|
|
28c386d916 | ||
|
|
5022877b04 | ||
|
|
aac862cddf | ||
|
|
08a806a643 | ||
|
|
5e5d8799a7 | ||
|
|
3e079a2fb4 | ||
|
|
464b41a435 | ||
|
|
3ad815cec4 | ||
|
|
85b6ca0639 | ||
|
|
8629fc956d | ||
|
|
292898de9e | ||
|
|
c26b6713de | ||
|
|
cec7556f9a | ||
|
|
d5340bf644 | ||
|
|
08948e8258 | ||
|
|
6447966e91 | ||
|
|
3080001d3d | ||
|
|
b8d9d29e90 | ||
|
|
ef275b8888 | ||
|
|
ce05ae202c | ||
|
|
ee7205bb84 | ||
|
|
46a6ded095 | ||
|
|
074c5824e8 | ||
|
|
6d8f70ac35 | ||
|
|
6329afc6a2 | ||
|
|
b31281c35e | ||
|
|
b3da0fc05c | ||
|
|
2549307a7e | ||
|
|
cf3b319fc7 | ||
|
|
11f78b2881 | ||
|
|
32e391e21e | ||
|
|
d2b95163fb | ||
|
|
c83f345977 | ||
|
|
db9e3e90c5 | ||
|
|
38ce25592f | ||
|
|
3ac502e1c3 | ||
|
|
0bf9eba043 | ||
|
|
d6633d94bf | ||
|
|
ac07023ca5 | ||
|
|
57e5eed47f | ||
|
|
6aa7058184 | ||
|
|
2509c0c951 | ||
|
|
b17043230f | ||
|
|
76487326f1 | ||
|
|
997aa7c922 | ||
|
|
22421e365c | ||
|
|
7c38f374f3 | ||
|
|
c422151ef8 | ||
|
|
3b805f6cc3 | ||
|
|
65a3443e7d | ||
|
|
0d17b69c3a | ||
|
|
7970066ccd | ||
|
|
eddd81e8d7 | ||
|
|
60daeb9225 | ||
|
|
361e811e93 | ||
|
|
38022bb3df | ||
|
|
31a9133eba | ||
|
|
663e607797 | ||
|
|
82c9e8f8ab | ||
|
|
3d8429fb12 | ||
|
|
5d2b5c1c81 | ||
|
|
c20a809014 | ||
|
|
b7858a03b5 | ||
|
|
27bf803539 | ||
|
|
0b64dcf4ec | ||
|
|
30631fb879 | ||
|
|
6c275a33ea | ||
|
|
fe00d162e1 | ||
|
|
3599ec077b | ||
|
|
a65ee51358 | ||
|
|
50b8f42312 | ||
|
|
ccaf0de63d | ||
|
|
9d8e79fb35 | ||
|
|
189ec60f4e | ||
|
|
6fcc9c23b8 | ||
|
|
d0515bb2a2 | ||
|
|
4f79fbfc41 | ||
|
|
5ccabe756a | ||
|
|
fb42b84245 | ||
|
|
ee5b6ea472 | ||
|
|
609df9eb1e | ||
|
|
20157f39ee | ||
|
|
c975f2f2d0 | ||
|
|
c9559419cd | ||
|
|
a064794c87 | ||
|
|
faee840073 | ||
|
|
dd8ed77153 | ||
|
|
27727a7a97 | ||
|
|
f3771dd645 | ||
|
|
52d909c374 | ||
|
|
ae69d186f6 | ||
|
|
04b7cdb24f | ||
|
|
8868ec6d68 | ||
|
|
15242666ce | ||
|
|
a5255a3621 | ||
|
|
76072b1be1 | ||
|
|
7485750b41 | ||
|
|
f7482fce5b | ||
|
|
e402fb93fb | ||
|
|
520ab99b21 | ||
|
|
742061183b | ||
|
|
8a16ddb3d0 | ||
|
|
af783e0532 | ||
|
|
d1806d9503 | ||
|
|
1ce894c134 | ||
|
|
8b1d7e5394 | ||
|
|
39a6832631 | ||
|
|
9694a9f5fd | ||
|
|
0683255281 | ||
|
|
3a76629f09 | ||
|
|
630e8611ba | ||
|
|
cc273e7312 | ||
|
|
c8da083526 | ||
|
|
840966c7f0 | ||
|
|
8ce7235c2b | ||
|
|
f70cef2cee | ||
|
|
dc2e2a82e7 | ||
|
|
c70fef3feb | ||
|
|
1afedac12f | ||
|
|
b8ff4b378b | ||
|
|
86069a7173 | ||
|
|
25db4eec9d | ||
|
|
9080a69e3c | ||
|
|
986fbf5418 | ||
|
|
df04363f7c | ||
|
|
57117e9043 | ||
|
|
5dd104a588 | ||
|
|
849365f839 | ||
|
|
e3e32f154c | ||
|
|
ab55852bb0 | ||
|
|
46bc1bacdd | ||
|
|
391a2004f4 | ||
|
|
2341749074 | ||
|
|
ac7431292e | ||
|
|
127b1577ac | ||
|
|
7562a3856d | ||
|
|
bc9ef95790 | ||
|
|
3617c17300 | ||
|
|
8e344bed20 | ||
|
|
eb59c11f85 | ||
|
|
339f472473 | ||
|
|
75412388e5 | ||
|
|
aad3acfe91 | ||
|
|
b9c012a587 | ||
|
|
d5bea54a83 | ||
|
|
c7ee0aab01 | ||
|
|
9b8a9e5aa3 | ||
|
|
40cec3a2f6 | ||
|
|
589880f1e3 | ||
|
|
6465c23127 | ||
|
|
203d058280 | ||
|
|
a116bf976a | ||
|
|
aa117054fb | ||
|
|
5335bf39b5 | ||
|
|
b263a69716 | ||
|
|
906da4d977 | ||
|
|
6c07c4763d | ||
|
|
1e6e101c4e | ||
|
|
4a627b6f03 | ||
|
|
a2b9a178b7 | ||
|
|
9ef1d0a605 | ||
|
|
0cf89600c0 | ||
|
|
3928564314 | ||
|
|
0d5ff2d964 | ||
|
|
b85f0b9489 | ||
|
|
5b6e4ac394 | ||
|
|
59a1bc03f2 | ||
|
|
01e27b2691 | ||
|
|
656e811e84 | ||
|
|
db60b99982 | ||
|
|
180d756dc0 | ||
|
|
a029ea3b0a | ||
|
|
4673354703 | ||
|
|
ded599328a | ||
|
|
7dfc6fd98c | ||
|
|
e0ed255fb4 | ||
|
|
d8f97c3b58 | ||
|
|
753cca7dfb | ||
|
|
7d61968f64 | ||
|
|
8637de27b9 | ||
|
|
447413e4b9 | ||
|
|
82c8ad7562 | ||
|
|
f2bbd97eeb | ||
|
|
c788b3358a | ||
|
|
b0672cce9e | ||
|
|
0681169653 | ||
|
|
ae6b86d233 | ||
|
|
8bba565609 | ||
|
|
78f08487c4 | ||
|
|
bdf7110135 | ||
|
|
9d35ece954 | ||
|
|
00e4cc1864 | ||
|
|
ad09012587 | ||
|
|
8b5de572f1 | ||
|
|
20c6b81047 | ||
|
|
aecead4a2d | ||
|
|
7b02edca0f | ||
|
|
109094d0e1 | ||
|
|
e0856a5274 | ||
|
|
c8886fb606 | ||
|
|
fd5f4dd139 | ||
|
|
0a51da13a4 | ||
|
|
5540ed9ce1 | ||
|
|
b6516dc40f | ||
|
|
8b57e13735 | ||
|
|
cb3dd716dd | ||
|
|
4895fb930c | ||
|
|
1f2985bbc3 | ||
|
|
32ad0f0926 | ||
|
|
3e448da0ba | ||
|
|
219e717021 | ||
|
|
1885b6a89e | ||
|
|
d8a5b8a701 | ||
|
|
d41e08b209 | ||
|
|
fc6d8e7b7d | ||
|
|
9518670c7b | ||
|
|
eff0b6eb23 | ||
|
|
f56af6402c | ||
|
|
ebcc814ca7 | ||
|
|
f7029c811c |
9
.github/ISSUE_TEMPLATE.md
vendored
Normal file
9
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
**What version of WebTorrent Desktop?** (See the 'About WebTorrent' menu)
|
||||
|
||||
**What operating system and version?**
|
||||
|
||||
**What did you do?**
|
||||
|
||||
**What did you expect to happen?**
|
||||
|
||||
**What actually happened?**
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
dist
|
||||
14
AUTHORS.md
14
AUTHORS.md
@@ -10,6 +10,18 @@
|
||||
- Romain Beaumont <romain.rom1@gmail.com>
|
||||
- Dan Flettre <fletd01@yahoo.com>
|
||||
- Liam Gray <liam.r.gray@gmail.com>
|
||||
- grunjol <grunjol@argenteam.net>
|
||||
- grunjol <grunjol@users.noreply.github.com>
|
||||
- Rémi Jouannet <remijouannet@users.noreply.github.com>
|
||||
- Evan Miller <miller.evan815@gmail.com>
|
||||
- Alex <alxmorais8@msn.com>
|
||||
- Diego Rodríguez Baquero <diegorbaquero@gmail.com>
|
||||
- Karlo Luis Martinez Martos <karlo.luis.m@gmail.com>
|
||||
- gabriel <furstenheim@gmail.com>
|
||||
- Rolando Guedes <rolando.guedes@3gnt.net>
|
||||
- Benjamin Tan <demoneaux@gmail.com>
|
||||
- Mathias Rasmussen <mathiasvr@gmail.com>
|
||||
- Sergey Bargamon <sergey@bargamon.ru>
|
||||
- Thomas Watson Steen <w@tson.dk>
|
||||
- Gediminas Petrikas <gedas18@gmail.com>
|
||||
|
||||
#### Generated by bin/update-authors.sh.
|
||||
|
||||
343
CHANGELOG.md
343
CHANGELOG.md
@@ -1,17 +1,348 @@
|
||||
# WebTorrent.app Version History
|
||||
# WebTorrent Desktop Version History
|
||||
|
||||
## v0.0.1
|
||||
## v0.8.0 - 2016-06-23
|
||||
|
||||
### Added
|
||||
|
||||
- Cast menu: choose which Chromecast, Airplay, or DLNA device you want to use
|
||||
- Telemetry: send basic data, plus stats on how often the play button works
|
||||
- Make posters from jpeg files, not just jpg
|
||||
- Support .wmv video via Play in VLC
|
||||
- Windows thumbnail bar with a play/pause button
|
||||
|
||||
### Changed
|
||||
|
||||
- Nicer modal styles
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows tray icon now stays in the right state
|
||||
|
||||
## v0.7.2 - 2016-06-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix exception that affects users upgrading from v0.5.1 or older
|
||||
- Ensure `state.saved.prefs` configuration exists
|
||||
- Fix window title on "About WebTorrent" window
|
||||
|
||||
## v0.7.1 - 2016-06-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Change "Step Forward" keyboard shortcut to `Alt+Left` (Windows)
|
||||
- Change "Step Backward" keyboard shortcut to to `Alt+Right` (Windows)
|
||||
|
||||
### Fixed
|
||||
|
||||
- First time startup bug -- invalid torrent/poster paths
|
||||
|
||||
## v0.7.0 - 2016-06-02
|
||||
|
||||
### Added
|
||||
|
||||
- Improved AirPlay support -- using the new [`airplayer`](https://www.npmjs.com/package/airplayer) package
|
||||
- Remember volume setting in player, for as long as the app is open
|
||||
|
||||
### Changed
|
||||
|
||||
- Add (+) button now also accepts non .torrent files and creates a torrent from
|
||||
those files
|
||||
- Show prompt text in title bar for open dialogs (OS X)
|
||||
- Upgrade Electron to 1.2.1
|
||||
- Improve window resizing when aspect ratio is enforced (OS X)
|
||||
- Use .ico format for better icon rendering quality (Windows)
|
||||
- Fix crash reporter not working (Windows)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Re-enable WebRTC (web peers)! (OS X, Windows)
|
||||
- Windows support was disabled in v0.6.1 to work around a bug in Electron
|
||||
- OS X support was disabled in v0.4.0 to work around a 100% CPU bug
|
||||
- Fix subtitle selector radio button UI size glitch
|
||||
- Fix race condition causing exeption on app startup
|
||||
- Fix duplicate torrent detection in some cases
|
||||
- Fix "gray screen" exception caused by incorrect file list order
|
||||
- Fix torrent loading message UI misalignment
|
||||
|
||||
### Known issues
|
||||
|
||||
- When upgrading to WebTorrent Desktop v0.7.0, some torrent metadata (file list,
|
||||
selected files, whether torrent is streamable) will be cleared. Just start the
|
||||
torrent to re-populate the metadata.
|
||||
|
||||
## v0.6.1 - 2016-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Disable WebRTC to work around Electron crash (Windows)
|
||||
- Will be re-enabled in the next version of WebTorrent, which will be based on
|
||||
the next version of Electron, where the bug is fixed.
|
||||
- Fix crash when updating from WebTorrent 0.5.x in some situtations (#583)
|
||||
- Fix crash when dropping files onto the dock icon (OS X)
|
||||
- Fix keyboard shortcuts Space and ESC being captured globally (#585)
|
||||
- Fix crash, show error when drag-dropping hidden files (#586)
|
||||
|
||||
## v0.6.0 - 2016-05-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added Preferences page to set Download folder
|
||||
- Save video position, resume playback from saved position
|
||||
- Add additional video player keyboard shortcuts (#275)
|
||||
- Use `poster.jpg` file as the poster image if available (#558)
|
||||
- Associate .torrent files to WebTorrent Desktop (OS X) (#553)
|
||||
- Add support for pasting `instant.io` links (#559)
|
||||
- Add announcement feature
|
||||
|
||||
### Changed
|
||||
|
||||
- Nicer player UI
|
||||
- Reduce startup jank, improve startup time (#568)
|
||||
- Cleanup unsupported codec detection (#569, #570)
|
||||
- Cleaner look for the torrent file list
|
||||
- Improve subtitle positioning (#551)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix Uncaught TypeError: Cannot read property 'update' of undefined (#567)
|
||||
- Fix bugs in LocationHistory
|
||||
- When player is active, and magnet link is pasted, go back to list
|
||||
- After deleting torrent, remove just the player from forward stack
|
||||
- After creating torrent, remove create torrent page from forward stack
|
||||
- Cancel button on create torrent page should only go back one page
|
||||
|
||||
## v0.5.1 - 2016-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix auto-updater (OS X, Windows).
|
||||
|
||||
## v0.5.0 - 2016-05-17
|
||||
|
||||
### Added
|
||||
|
||||
- Select/deselect individual files to torrent.
|
||||
- Automatically include subtitle files (.srt, .vtt) from torrent in the subtitles menu.
|
||||
- "Add Subtitle File..." menu item.
|
||||
|
||||
### Changed
|
||||
|
||||
- When manually adding subtitle track(s), always switch to the new track.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Magnet links throw exception on app launch. (OS X)
|
||||
- Multi-file torrents would not seed in-place, were copied to Downloads folder.
|
||||
- Missing 'About WebTorrent' menu item. (Windows)
|
||||
- Rare exception. ("Cannot create BrowserWindow before app is ready")
|
||||
|
||||
## v0.4.0 - 2016-05-13
|
||||
|
||||
### Added
|
||||
|
||||
- Better Windows support!
|
||||
- Windows 32-bit build.
|
||||
- Windows Portable App build.
|
||||
- Windows app signing, for fewer install warnings.
|
||||
- Better Linux support!
|
||||
- Linux 32-bit build.
|
||||
- Subtitles support!
|
||||
- .srt and .vtt file support.
|
||||
- Drag-and-drop files on video, or choose from file selector.
|
||||
- Multiple subtitle files support.
|
||||
- Stream to VLC when the audio codec is unplayable (e.g. AC3, EAC3).
|
||||
- "Show in Folder" item in context menu.
|
||||
- Volume slider, with mute/unmute button.
|
||||
- New "Create torrent" page to modify:
|
||||
- Torrent comment.
|
||||
- Trackers.
|
||||
- Private torrent flag.
|
||||
- Use mouse wheel to increase/decrease volume.
|
||||
- Bounce the Downloads stack when download completes. (OS X)
|
||||
- New default torrent on first launch: The WIRED CD.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve app startup time by 40%.
|
||||
- UI tweaks: Reduce font size, reduce torrent list item height.
|
||||
- Add Playback menu for playback-related functionality.
|
||||
- Fix installing when the app is already installed. (Windows)
|
||||
- Don't kill unrelated processes on uninstall. (Windows)
|
||||
- Set "sheet offset" correctly for create torrent dialog. (OS X)
|
||||
- Remove OS X-style Window menu. (Linux, Windows)
|
||||
- Remove "Add Fake Airplay/Chromecast" menu items.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Disable WebRTC to fix 100% CPU usage/crashes caused by Chromium issue. This is
|
||||
temporary. (OS X)
|
||||
- When fullscreen, make controls use the full window. (OS X)
|
||||
- Support creating torrents that contain .torrent files.
|
||||
- Block power save while casting to a remote device.
|
||||
- Do not block power save when the space key is pressed from the torrent list.
|
||||
- Support playing .mpg and .ogv extensions in the app.
|
||||
- Fix video centering for multi-screen setups.
|
||||
- Show an error when adding a duplicate torrent.
|
||||
- Show an error when adding an invalid magnet link.
|
||||
- Do not stop music when tabbing to another program (OS X)
|
||||
- Properly size the Windows volume mixer icon.
|
||||
- Default to the user's OS-defined, localized "Downloads" folder.
|
||||
- Enforce minimimum window size when resizing player to prevent window disappearing.
|
||||
- Fix rare race condition error on app quit.
|
||||
- Don't use zero-byte torrent "poster" images.
|
||||
|
||||
Thanks to @grunjol, @rguedes, @furstenheim, @karloluis, @DiegoRBaquero, @alxhotel,
|
||||
@AgentEpsilon, @remijouannet, Rolando Guedes, @dcposch, and @feross for contributing
|
||||
to this release!
|
||||
|
||||
## v0.3.3 - 2016-04-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- App icon was incorrect (OS X)
|
||||
|
||||
## v0.3.2 - 2016-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- Register WebTorrent as default handler for magnet links (OS X)
|
||||
|
||||
### Changed
|
||||
|
||||
- Faster startup time (50ms)
|
||||
- Update Electron to 0.37.5
|
||||
- Remove the white flash when loading pages and resizing the window
|
||||
- Fix crash when sending IPC messages
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix installation bugs with .deb file (Linux)
|
||||
- Pause audio reliably when closing the window
|
||||
- Enforce minimimum window size when resizing player (for audio-only .mov files, which are 0x0)
|
||||
|
||||
## v0.3.1 - 2016-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- Add crash reporter to torrent engine process
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix cast screen background: cover, don't tile
|
||||
|
||||
## v0.3.0 - 2016-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Ubuntu/Debian support!** (.deb installer)
|
||||
- **DLNA streaming support**
|
||||
- Add "File > Quit" menu item (Linux)
|
||||
- App uninstaller (Windows)
|
||||
- Crash reporting
|
||||
|
||||
### Changed
|
||||
|
||||
- On startup, do not re-verify files when timestamps are unchanged
|
||||
- Moved torrent engine to an independent process, for better UI performance
|
||||
- Removed media queries (UI resizing based on window width)
|
||||
- Improved Chromecast icon, when connected
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Download Complete" notification shows consistently
|
||||
- Create new torrents and seed them without copying to temporary folder
|
||||
- Clicking the "Download Complete" notification will always activate app
|
||||
- Fixed harmless "-psn_###" error on first app startup
|
||||
- Hide play buttons on unplayable torrents
|
||||
- Better error handling when Chromecast/Airplay cannot connect
|
||||
- Show player controls immediately on mouse move
|
||||
- When creating a torrent, show it in UI immediately
|
||||
- Stop casting to TV when player is closed
|
||||
- Torrent engine: Fixed memory leaks in `torrent-discovery` and `bittorrent-tracker`
|
||||
- Torrent engine: Fixed sub-optimal tcp/webrtc connection timeouts
|
||||
- Torrent engine: Throttle web seed connections to maximum of 4
|
||||
|
||||
Thanks to @dcposch, @grunjol, and @feross for contributing to this release.
|
||||
|
||||
## v0.2.0 - 2016-03-29
|
||||
|
||||
### Added
|
||||
|
||||
- Minimise to tray (Windows, Linux)
|
||||
- Show spinner and download speed when player is stalled waiting for data
|
||||
- Highlight window on drag-and-drop
|
||||
- Show notification to update to new app version (Linux)
|
||||
- We have an auto-updater for Windows and Mac. We don't have one for Linux yet, so
|
||||
Linux users need to download new versions manually.
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed WebTorrent.app to WebTorrent Desktop
|
||||
- Add Cosmos Laundromat as a default torrent
|
||||
|
||||
### Fixed
|
||||
|
||||
- Only capture media keys when player is active
|
||||
- Update WebTorrent to 0.88.1 for performance improvements
|
||||
- When seeding, do not proactively connect to new peers
|
||||
- When seeding, do not accept new peers from peer exchange (ut_pex)
|
||||
- Fixed leaks, and other improvements that result in less garbage collection
|
||||
|
||||
Thanks to @dcposch, @ungoldman, and @feross for contributing to this release.
|
||||
|
||||
## v0.1.1 - 2016-03-28
|
||||
|
||||
- Performance improvements
|
||||
- Improve app startup time by over 100%
|
||||
- Reduce the number of DOM updates substantially
|
||||
- Update UI immediately anytime state is changed, instead on 1 second interval
|
||||
- Added right-click menu
|
||||
- Save .torrent File
|
||||
- Copy Instant.io Link to Clipboard
|
||||
- Copy Magnet Link to Clipbaord
|
||||
- Added keyboard shortcut for volume up (⌘/Ctrl + ↑) and volume down (⌘/Ctrl + ↓)
|
||||
- Add desktop launcher shortcuts, like OS X has, for KDE and GNOME (Linux)
|
||||
- Add "About" window (Windows, Linux)
|
||||
- Better default window size that fits all the default torrents
|
||||
- Fixed
|
||||
- Crash when ".local/share/{applications,icons}" path did not exist (Linux)
|
||||
- WebTorrent executable can be moved without breaking torrents in the client
|
||||
- Video progress bar shows progress for current file, not full torrent
|
||||
- Video player window shows file title instead of torrent title
|
||||
|
||||
Thanks to @dcposch, @ungoldman, @rom1504, @grunjol, @Flet, and @feross for contributing to
|
||||
this release.
|
||||
|
||||
## v0.1.0 - 2016-03-25
|
||||
|
||||
- **Windows support!**
|
||||
- Includes auto-updater, just like the OS X version.
|
||||
- Installs desktop and start menu shortcuts.
|
||||
- **Audio file support!**
|
||||
- Supports playback of .mp3, .aac, .ogg, .wav
|
||||
- Audio file metadata gets shown in the UI
|
||||
- Top menu is no longer automatically hidden (Windows)
|
||||
- When magnet links are opened from third-party apps, the WebTorrent window now gets focus.
|
||||
- Subtler app sounds.
|
||||
- Fix for an issue that caused some magnet links to fail to open.
|
||||
|
||||
**NOTE:** OS X users must install v0.1.0 manually because the app bundle ID was changed in this release, and the auto-updater cannot handle this condition.
|
||||
|
||||
Thanks to @dcposch, @ungoldman, and @feross for contributing to this release.
|
||||
|
||||
## v0.0.1 - 2016-03-21
|
||||
|
||||
- Wait 10 seconds (instead of 60 seconds) after app launch before checking for updates.
|
||||
|
||||
## v0.0.0
|
||||
## v0.0.0 - 2016-03-21
|
||||
|
||||
The first official release of WebTorrent.app, the streaming torrent client for OS X,
|
||||
The first official release of WebTorrent Desktop, the streaming torrent client for OS X,
|
||||
Windows, and Linux. For now, we're only releasing binaries for OS X.
|
||||
|
||||
WebTorrent.app is in ALPHA and under very active development – expect lots more polish in
|
||||
WebTorrent Desktop is in ALPHA and under very active development – expect lots more polish in
|
||||
the coming weeks! If you know JavaScript and want to help us out, there's
|
||||
[lots to do](https://github.com/feross/webtorrent-app/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+contribution%22)!
|
||||
[lots to do](https://github.com/feross/webtorrent-desktop/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+contribution%22)!
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -4,44 +4,72 @@ Contributions welcome!
|
||||
|
||||
**Before spending lots of time on something, ask for feedback on your idea first!**
|
||||
|
||||
Please search issues and pull requests before adding something new to avoid duplicating efforts and conversations.
|
||||
Please search issues and pull requests before adding something new to avoid duplicating
|
||||
efforts and conversations.
|
||||
|
||||
This project welcomes non-code contributions, too! The following types of contributions are welcome:
|
||||
This project welcomes non-code contributions, too! The following types of contributions
|
||||
are welcome:
|
||||
|
||||
- **Ideas**: participate in an issue thread or start your own to have your voice heard.
|
||||
- **Writing**: contribute your expertise in an area by helping expand the included content.
|
||||
- **Copy editing**: fix typos, clarify language, and generally improve the quality of the content.
|
||||
- **Formatting**: help keep content easy to read with consistent formatting.
|
||||
- **Writing**: contribute your expertise in an area by helping expand the included docs.
|
||||
- **Copy editing**: fix typos, clarify language, and improve the quality of the docs.
|
||||
- **Formatting**: help keep docs easy to read with consistent formatting.
|
||||
|
||||
## Code Style
|
||||
|
||||
[![standard][standard-image]][standard-url]
|
||||
|
||||
This repository uses [`standard`][standard-url] to maintain code style and consistency, and to avoid style arguments. `npm test` runs `standard` automatically, so you don't have to!
|
||||
This repository uses [`standard`][standard-url] to maintain code style and consistency,
|
||||
and to avoid style arguments. `npm test` runs `standard` automatically, so you don't have
|
||||
to!
|
||||
|
||||
[standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg
|
||||
[standard-url]: https://github.com/feross/standard
|
||||
|
||||
# Project Governance
|
||||
## Project Governance
|
||||
|
||||
**This is an [OPEN Open Source Project](http://openopensource.org/).**
|
||||
Individuals making significant and valuable contributions are given commit-access to the
|
||||
project to contribute as they see fit. This project is more like an open wiki than a
|
||||
standard guarded open source project.
|
||||
|
||||
Individuals making significant and valuable contributions are given commit-access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project.
|
||||
|
||||
## Rules
|
||||
### Rules
|
||||
|
||||
There are a few basic ground-rules for contributors:
|
||||
|
||||
1. **No `--force` pushes** or modifying the Git history in any way.
|
||||
1. **Non-master branches** ought to be used for ongoing work.
|
||||
1. **External API changes and significant modifications** ought to be subject to an **internal pull-request** to solicit feedback from other contributors.
|
||||
1. Internal pull-requests to solicit feedback are *encouraged* for any other non-trivial contribution but left to the discretion of the contributor.
|
||||
1. Contributors should attempt to adhere to the prevailing code style.
|
||||
2. **Non-master branches** should be used for ongoing work.
|
||||
3. **Significant modifications** like API changes should be subject to a **pull request**
|
||||
to solicit feedback from other contributors.
|
||||
4. **Pull requests** are *encouraged* for all contributions to solicit feedback, but left to
|
||||
the discretion of the contributor.
|
||||
|
||||
## Releases
|
||||
### Releases
|
||||
|
||||
Declaring formal releases remains the prerogative of the project maintainer.
|
||||
|
||||
## Changes to this arrangement
|
||||
### Changes to this arrangement
|
||||
|
||||
This is an experiment and feedback is welcome! This document may also be subject to pull-requests or changes by contributors where you believe you have something valuable to add or change.
|
||||
This is an experiment and feedback is welcome! This document may also be subject to pull-
|
||||
requests or changes by contributors where you believe you have something valuable to add
|
||||
or change.
|
||||
|
||||
## Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
- (a) The contribution was created in whole or in part by me and I have the right to
|
||||
submit it under the open source license indicated in the file; or
|
||||
|
||||
- (b) The contribution is based upon previous work that, to the best of my knowledge, is
|
||||
covered under an appropriate open source license and I have the right under that license
|
||||
to submit that work with modifications, whether created in whole or in part by me, under
|
||||
the same open source license (unless I am permitted to submit under a different
|
||||
license), as indicated in the file; or
|
||||
|
||||
- (c) The contribution was provided directly to me by some other person who certified
|
||||
(a), (b) or (c) and I have not modified it.
|
||||
|
||||
- (d) I understand and agree that this project and the contribution are public and that a
|
||||
record of the contribution (including all personal information I submit with it,
|
||||
including my sign-off) is maintained indefinitely and may be redistributed consistent
|
||||
with this project or the open source license(s) involved.
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Feross Aboukhadijeh
|
||||
Copyright (c) WebTorrent, LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
|
||||
53
README.md
53
README.md
@@ -2,7 +2,7 @@
|
||||
<br>
|
||||
<a href="https://webtorrent.io"><img src="https://webtorrent.io/img/WebTorrent.png" alt="WebTorrent" width="200"></a>
|
||||
<br>
|
||||
WebTorrent.app
|
||||
WebTorrent Desktop
|
||||
<br>
|
||||
<br>
|
||||
</h1>
|
||||
@@ -10,24 +10,19 @@
|
||||
<h4 align="center">The streaming torrent client. For OS X, Windows, and Linux.</h4>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gitter.im/feross/webtorrent">
|
||||
<img src="https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg"
|
||||
alt="Gitter">
|
||||
</a>
|
||||
<a href="https://travis-ci.org/feross/webtorrent-app">
|
||||
<img src="https://img.shields.io/travis/feross/webtorrent-app/master.svg"
|
||||
alt="Travis Build">
|
||||
</a>
|
||||
<a href="https://gitter.im/feross/webtorrent"><img src="https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg" alt="Gitter"></a>
|
||||
<a href="https://travis-ci.org/feross/webtorrent-desktop"><img src="https://img.shields.io/travis/feross/webtorrent-desktop/master.svg" alt="Travis"></a>
|
||||
<a href="https://github.com/feross/webtorrent-desktop/releases"><img src="https://img.shields.io/github/release/feross/webtorrent-desktop.svg" alt="Release"></a>
|
||||
</p>
|
||||
|
||||
## Install
|
||||
|
||||
**WebTorrent.app** is still under very active development. Expect a release very soon!
|
||||
**WebTorrent Desktop** is still under very active development. You can download the latest version from the [releases](https://github.com/feross/webtorrent-desktop/releases) page.
|
||||
|
||||
## Screenshot
|
||||
|
||||
<p align="center">
|
||||
<img src="./static/screenshot.png" width="562" height="630" alt="screenshot" align="center">
|
||||
<img src="https://webtorrent.io/img/screenshot-main.png" width="562" height="630" alt="screenshot" align="center">
|
||||
</p>
|
||||
|
||||
## How to Contribute
|
||||
@@ -58,10 +53,37 @@ To build for one platform:
|
||||
$ npm run package -- [platform]
|
||||
```
|
||||
|
||||
Where `[platform]` is `--darwin`, `--linux`, or `--win32`.
|
||||
Where `[platform]` is `darwin`, `linux`, `win32`, or `all` (default).
|
||||
|
||||
To package a Windows app from non-Windows platforms, [Wine](https://www.winehq.org/) needs
|
||||
to be installed. On OS X, it is installable via [Homebrew](http://brew.sh/).
|
||||
The following optional arguments are available:
|
||||
|
||||
- `--sign` - Sign the application (OS X, Windows)
|
||||
- `--package=[type]` - Package single output type.
|
||||
- `deb` - Debian package
|
||||
- `zip` - Linux zip file
|
||||
- `dmg` - OS X disk image
|
||||
- `exe` - Windows installer
|
||||
- `portable` - Windows portable app
|
||||
- `all` - All platforms (default)
|
||||
|
||||
Note: Even with the `--package` option, the auto-update files (.nupkg for Windows, *-darwin.zip for OS X) will always be produced.
|
||||
|
||||
#### Windows build notes
|
||||
|
||||
To package the Windows app from non-Windows platforms, [Wine](https://www.winehq.org/) needs
|
||||
to be installed.
|
||||
|
||||
On OS X, first install [XQuartz](http://www.xquartz.org/), then run:
|
||||
|
||||
```
|
||||
brew install wine
|
||||
```
|
||||
|
||||
(Requires the [Homebrew](http://brew.sh/) package manager.)
|
||||
|
||||
### Privacy
|
||||
|
||||
WebTorrent Desktop collects some basic usage stats to help us make the app better. For example, we track what OSs are users are on, and how well the play button works (how often does it succeed? time out? show a missing codec error?). The app never sends personally identifying or other private info.
|
||||
|
||||
### Code Style
|
||||
|
||||
@@ -69,5 +91,4 @@ to be installed. On OS X, it is installable via [Homebrew](http://brew.sh/).
|
||||
|
||||
## License
|
||||
|
||||
MIT. Copyright (c) [Feross Aboukhadijeh](http://feross.org).
|
||||
|
||||
MIT. Copyright (c) [WebTorrent, LLC](https://webtorrent.io).
|
||||
|
||||
99
bin/check-deps.js
Executable file
99
bin/check-deps.js
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
var fs = require('fs')
|
||||
var cp = require('child_process')
|
||||
|
||||
// We can't use `builtin-modules` here since our TravisCI
|
||||
// setup expects this file to run with no dependencies
|
||||
var BUILT_IN_NODE_MODULES = [
|
||||
'assert',
|
||||
'buffer',
|
||||
'child_process',
|
||||
'cluster',
|
||||
'console',
|
||||
'constants',
|
||||
'crypto',
|
||||
'dgram',
|
||||
'dns',
|
||||
'domain',
|
||||
'events',
|
||||
'fs',
|
||||
'http',
|
||||
'https',
|
||||
'module',
|
||||
'net',
|
||||
'os',
|
||||
'path',
|
||||
'process',
|
||||
'punycode',
|
||||
'querystring',
|
||||
'readline',
|
||||
'repl',
|
||||
'stream',
|
||||
'string_decoder',
|
||||
'timers',
|
||||
'tls',
|
||||
'tty',
|
||||
'url',
|
||||
'util',
|
||||
'v8',
|
||||
'vm',
|
||||
'zlib'
|
||||
]
|
||||
|
||||
var BUILT_IN_ELECTRON_MODULES = [ 'electron' ]
|
||||
|
||||
var BUILT_IN_DEPS = [].concat(BUILT_IN_NODE_MODULES, BUILT_IN_ELECTRON_MODULES)
|
||||
|
||||
var EXECUTABLE_DEPS = ['gh-release', 'standard']
|
||||
|
||||
main()
|
||||
|
||||
// Scans codebase for missing or unused dependencies. Exits with code 0 on success.
|
||||
function main () {
|
||||
if (process.platform === 'win32') {
|
||||
console.error('Sorry, check-deps only works on Mac and Linux')
|
||||
return
|
||||
}
|
||||
|
||||
var usedDeps = findUsedDeps()
|
||||
var packageDeps = findPackageDeps()
|
||||
|
||||
var missingDeps = usedDeps.filter(
|
||||
(dep) => !includes(packageDeps, dep) && !includes(BUILT_IN_DEPS, dep)
|
||||
)
|
||||
var unusedDeps = packageDeps.filter(
|
||||
(dep) => !includes(usedDeps, dep) && !includes(EXECUTABLE_DEPS, dep)
|
||||
)
|
||||
|
||||
if (missingDeps.length > 0) {
|
||||
console.error('Missing package dependencies: ' + missingDeps)
|
||||
}
|
||||
if (unusedDeps.length > 0) {
|
||||
console.error('Unused package dependencies: ' + unusedDeps)
|
||||
}
|
||||
if (missingDeps.length + unusedDeps.length > 0) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Finds all dependencies specified in `package.json`
|
||||
function findPackageDeps () {
|
||||
var pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'))
|
||||
|
||||
var deps = Object.keys(pkg.dependencies)
|
||||
var devDeps = Object.keys(pkg.devDependencies)
|
||||
var optionalDeps = Object.keys(pkg.optionalDependencies)
|
||||
|
||||
return [].concat(deps, devDeps, optionalDeps)
|
||||
}
|
||||
|
||||
// Finds all dependencies that used with `require()`
|
||||
function findUsedDeps () {
|
||||
var stdout = cp.execSync('./bin/list-deps.sh')
|
||||
return stdout.toString().trim().split('\n')
|
||||
}
|
||||
|
||||
function includes (arr, elem) {
|
||||
return arr.indexOf(elem) >= 0
|
||||
}
|
||||
18
bin/clean.js
18
bin/clean.js
@@ -1,17 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Remove all traces of WebTorrent.app from the system (config and temp files).
|
||||
* Remove all traces of WebTorrent Desktop from the system (config and temp files).
|
||||
* Useful for developers.
|
||||
*/
|
||||
|
||||
var config = require('../config')
|
||||
var fs = require('fs')
|
||||
var os = require('os')
|
||||
var path = require('path')
|
||||
var pathExists = require('path-exists')
|
||||
var rimraf = require('rimraf')
|
||||
|
||||
var config = require('../config')
|
||||
var handlers = require('../main/handlers')
|
||||
|
||||
rimraf.sync(config.CONFIG_PATH)
|
||||
|
||||
var tmpPath = path.join(pathExists.sync('/tmp') ? '/tmp' : os.tmpDir(), 'webtorrent')
|
||||
var tmpPath
|
||||
try {
|
||||
tmpPath = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent')
|
||||
} catch (err) {
|
||||
tmpPath = path.join(os.tmpDir(), 'webtorrent')
|
||||
}
|
||||
rimraf.sync(tmpPath)
|
||||
|
||||
// Uninstall .torrent file and magnet link handlers
|
||||
handlers.uninstall()
|
||||
|
||||
10
bin/cmd.js
Executable file
10
bin/cmd.js
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
var electron = require('electron-prebuilt')
|
||||
var cp = require('child_process')
|
||||
var path = require('path')
|
||||
|
||||
var child = cp.spawn(electron, [path.join(__dirname, '..')], {stdio: 'inherit'})
|
||||
child.on('close', function (code) {
|
||||
process.exitCode = code
|
||||
})
|
||||
10
bin/list-deps.sh
Executable file
10
bin/list-deps.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# This is a truly heinous hack, but it works pretty nicely.
|
||||
# Find all modules we're requiring---even conditional requires.
|
||||
|
||||
grep "require('" *.js bin/ main/ renderer/ -R |
|
||||
grep '.js:' |
|
||||
sed "s/.*require('\([^'\/]*\).*/\1/" |
|
||||
grep -v '^\.' |
|
||||
sort |
|
||||
uniq
|
||||
6
bin/open-config.js
Executable file
6
bin/open-config.js
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
var config = require('../config')
|
||||
var open = require('open')
|
||||
|
||||
open(config.CONFIG_PATH)
|
||||
382
bin/package.js
382
bin/package.js
@@ -4,28 +4,50 @@
|
||||
* Builds app binaries for OS X, Linux, and Windows.
|
||||
*/
|
||||
|
||||
var config = require('../config')
|
||||
var cp = require('child_process')
|
||||
var electronPackager = require('electron-packager')
|
||||
var fs = require('fs')
|
||||
var minimist = require('minimist')
|
||||
var mkdirp = require('mkdirp')
|
||||
var os = require('os')
|
||||
var path = require('path')
|
||||
var rimraf = require('rimraf')
|
||||
var series = require('run-series')
|
||||
var zip = require('cross-zip')
|
||||
|
||||
var config = require('../config')
|
||||
var pkg = require('../package.json')
|
||||
|
||||
var BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
|
||||
var DIST_PATH = path.join(config.ROOT_PATH, 'dist')
|
||||
|
||||
var argv = minimist(process.argv.slice(2), {
|
||||
boolean: [
|
||||
'sign'
|
||||
],
|
||||
default: {
|
||||
package: 'all',
|
||||
sign: false
|
||||
},
|
||||
string: [
|
||||
'package'
|
||||
]
|
||||
})
|
||||
|
||||
function build () {
|
||||
var platform = process.argv[2]
|
||||
if (platform === '--darwin') {
|
||||
rimraf.sync(DIST_PATH)
|
||||
var platform = argv._[0]
|
||||
if (platform === 'darwin') {
|
||||
buildDarwin(printDone)
|
||||
} else if (platform === '--win32') {
|
||||
} else if (platform === 'win32') {
|
||||
buildWin32(printDone)
|
||||
} else if (platform === '--linux') {
|
||||
} else if (platform === 'linux') {
|
||||
buildLinux(printDone)
|
||||
} else {
|
||||
buildDarwin(function (err, buildPath) {
|
||||
printDone(err, buildPath)
|
||||
buildWin32(function (err, buildPath) {
|
||||
printDone(err, buildPath)
|
||||
buildDarwin(function (err) {
|
||||
printDone(err)
|
||||
buildWin32(function (err) {
|
||||
printDone(err)
|
||||
buildLinux(printDone)
|
||||
})
|
||||
})
|
||||
@@ -33,11 +55,9 @@ function build () {
|
||||
}
|
||||
|
||||
var all = {
|
||||
// Build 64 bit binaries only.
|
||||
arch: 'x64',
|
||||
|
||||
// The application source directory.
|
||||
dir: config.ROOT_PATH,
|
||||
// The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata
|
||||
// property on Windows, and `NSHumanReadableCopyright` on OS X.
|
||||
'app-copyright': config.APP_COPYRIGHT,
|
||||
|
||||
// The release version of the application. Maps to the `ProductVersion` metadata
|
||||
// property on Windows, and `CFBundleShortVersionString` on OS X.
|
||||
@@ -53,19 +73,22 @@ var all = {
|
||||
'asar-unpack': 'WebTorrent*',
|
||||
|
||||
// The build version of the application. Maps to the FileVersion metadata property on
|
||||
// Windows, and CFBundleVersion on OS X. We're using the short git hash (e.g. 'e7d837e')
|
||||
// Windows requires the build version to start with a number :/ so we stick on a prefix
|
||||
'build-version': '0-' + cp.execSync('git rev-parse --short HEAD').toString().replace('\n', ''),
|
||||
// Windows, and CFBundleVersion on OS X. Note: Windows requires the build version to
|
||||
// start with a number. We're using the version of the underlying WebTorrent library.
|
||||
'build-version': require('webtorrent/package.json').version,
|
||||
|
||||
// The application source directory.
|
||||
dir: config.ROOT_PATH,
|
||||
|
||||
// Pattern which specifies which files to ignore when copying files to create the
|
||||
// package(s).
|
||||
ignore: /^\/dist|\/(appveyor.yml|AUTHORS|CONTRIBUTORS|bench|benchmark|benchmark\.js|bin|bower\.json|component\.json|coverage|doc|docs|docs\.mli|dragdrop\.min\.js|example|examples|example\.html|example\.js|externs|ipaddr\.min\.js|Makefile|min|minimist|perf|rusha|simplepeer\.min\.js|simplewebsocket\.min\.js|static\/screenshot\.png|test|tests|test\.js|tests\.js|webtorrent\.min\.js|\.[^\/]*|.*\.md|.*\.markdown)$/,
|
||||
ignore: /^\/dist|\/(appveyor.yml|\.appveyor.yml|\.github|appdmg|AUTHORS|CONTRIBUTORS|bench|benchmark|benchmark\.js|bin|bower\.json|component\.json|coverage|doc|docs|docs\.mli|dragdrop\.min\.js|example|examples|example\.html|example\.js|externs|ipaddr\.min\.js|Makefile|min|minimist|perf|rusha|simplepeer\.min\.js|simplewebsocket\.min\.js|static\/screenshot\.png|test|tests|test\.js|tests\.js|webtorrent\.min\.js|\.[^\/]*|.*\.md|.*\.markdown)$/,
|
||||
|
||||
// The application name.
|
||||
name: config.APP_NAME,
|
||||
|
||||
// The base directory where the finished package(s) are created.
|
||||
out: path.join(config.ROOT_PATH, 'dist'),
|
||||
out: DIST_PATH,
|
||||
|
||||
// Replace an already existing output directory.
|
||||
overwrite: true,
|
||||
@@ -75,45 +98,50 @@ var all = {
|
||||
prune: true,
|
||||
|
||||
// The Electron version with which the app is built (without the leading 'v')
|
||||
version: pkg.devDependencies['electron-prebuilt']
|
||||
version: require('electron-prebuilt/package.json').version
|
||||
}
|
||||
|
||||
var darwin = {
|
||||
// Build for OS X
|
||||
platform: 'darwin',
|
||||
|
||||
// Build 64 bit binaries only.
|
||||
arch: 'x64',
|
||||
|
||||
// The bundle identifier to use in the application's plist (OS X only).
|
||||
'app-bundle-id': 'io.webtorrent.app',
|
||||
'app-bundle-id': 'io.webtorrent.webtorrent',
|
||||
|
||||
// The application category type, as shown in the Finder via "View" -> "Arrange by
|
||||
// Application Category" when viewing the Applications directory (OS X only).
|
||||
'app-category-type': 'public.app-category.utilities',
|
||||
|
||||
// The bundle identifier to use in the application helper's plist (OS X only).
|
||||
'helper-bundle-id': 'io.webtorrent.app.helper',
|
||||
'helper-bundle-id': 'io.webtorrent.webtorrent-helper',
|
||||
|
||||
// Application icon.
|
||||
icon: config.APP_ICON + '.icns'
|
||||
}
|
||||
|
||||
var win32 = {
|
||||
// Build for Windows.
|
||||
platform: 'win32',
|
||||
|
||||
// Build 32 bit binaries only.
|
||||
arch: 'ia32',
|
||||
|
||||
// Object hash of application metadata to embed into the executable (Windows only)
|
||||
'version-string': {
|
||||
|
||||
// Company that produced the file.
|
||||
CompanyName: config.APP_NAME,
|
||||
|
||||
// Copyright notices that apply to the file.
|
||||
LegalCopyright: config.APP_COPYRIGHT,
|
||||
|
||||
// Name of the program, displayed to users
|
||||
FileDescription: config.APP_NAME,
|
||||
|
||||
// Original name of the file, not including a path. This information enables an
|
||||
// application to determine whether a file has been renamed by a user. The format of
|
||||
// the name depends on the file system for which the file was created.
|
||||
OriginalFilename: 'WebTorrent.exe',
|
||||
OriginalFilename: config.APP_NAME + '.exe',
|
||||
|
||||
// Name of the product with which the file is distributed.
|
||||
ProductName: config.APP_NAME,
|
||||
@@ -129,7 +157,11 @@ var win32 = {
|
||||
}
|
||||
|
||||
var linux = {
|
||||
platform: 'linux'
|
||||
// Build for Linux.
|
||||
platform: 'linux',
|
||||
|
||||
// Build 32 and 64 bit binaries.
|
||||
arch: 'all'
|
||||
|
||||
// Note: Application icon for Linux is specified via the BrowserWindow `icon` option.
|
||||
}
|
||||
@@ -137,12 +169,12 @@ var linux = {
|
||||
build()
|
||||
|
||||
function buildDarwin (cb) {
|
||||
var appDmg = require('appdmg')
|
||||
var plist = require('plist')
|
||||
var sign = require('electron-osx-sign')
|
||||
|
||||
console.log('OS X: Packaging electron...')
|
||||
electronPackager(Object.assign({}, all, darwin), function (err, buildPath) {
|
||||
if (err) return cb(err)
|
||||
console.log('OS X: Packaged electron. ' + buildPath)
|
||||
|
||||
var appPath = path.join(buildPath[0], config.APP_NAME + '.app')
|
||||
var contentsPath = path.join(appPath, 'Contents')
|
||||
@@ -174,10 +206,33 @@ function buildDarwin (cb) {
|
||||
CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
|
||||
CFBundleURLName: 'BitTorrent Magnet URL',
|
||||
CFBundleURLSchemes: [ 'magnet' ]
|
||||
},
|
||||
{
|
||||
CFBundleTypeRole: 'Editor',
|
||||
CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
|
||||
CFBundleURLName: 'BitTorrent Stream-Magnet URL',
|
||||
CFBundleURLSchemes: [ 'stream-magnet' ]
|
||||
}
|
||||
]
|
||||
|
||||
infoPlist.NSHumanReadableCopyright = config.APP_COPYRIGHT
|
||||
infoPlist.UTExportedTypeDeclarations = [
|
||||
{
|
||||
UTTypeConformsTo: [
|
||||
'public.data',
|
||||
'public.item',
|
||||
'com.bittorrent.torrent'
|
||||
],
|
||||
UTTypeDescription: 'BitTorrent Document',
|
||||
UTTypeIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
|
||||
UTTypeIdentifier: 'org.bittorrent.torrent',
|
||||
UTTypeReferenceURL: 'http://www.bittorrent.org/beps/bep_0000.html',
|
||||
UTTypeTagSpecification: {
|
||||
'com.apple.ostype': 'TORR',
|
||||
'public.filename-extension': [ 'torrent' ],
|
||||
'public.mime-type': 'application/x-bittorrent'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
fs.writeFileSync(infoPlistPath, plist.build(infoPlist))
|
||||
|
||||
@@ -185,8 +240,24 @@ function buildDarwin (cb) {
|
||||
cp.execSync(`cp ${config.APP_FILE_ICON + '.icns'} ${resourcesPath}`)
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
if (argv.sign) {
|
||||
signApp(function (err) {
|
||||
if (err) return cb(err)
|
||||
pack(cb)
|
||||
})
|
||||
} else {
|
||||
printWarning()
|
||||
pack(cb)
|
||||
}
|
||||
} else {
|
||||
printWarning()
|
||||
}
|
||||
|
||||
function signApp (cb) {
|
||||
var sign = require('electron-osx-sign')
|
||||
|
||||
/*
|
||||
* Sign the app with Apple Developer ID certificate. We sign the app for 2 reasons:
|
||||
* Sign the app with Apple Developer ID certificates. We sign the app for 2 reasons:
|
||||
* - So the auto-updater (Squirrrel.Mac) can check that app updates are signed by
|
||||
* the same author as the current version.
|
||||
* - So users will not a see a warning about the app coming from an "Unidentified
|
||||
@@ -204,57 +275,236 @@ function buildDarwin (cb) {
|
||||
verbose: true
|
||||
}
|
||||
|
||||
console.log('OS X: Signing app...')
|
||||
sign(signOpts, function (err) {
|
||||
if (err) return cb(err)
|
||||
console.log('OS X: Signed app.')
|
||||
cb(null)
|
||||
})
|
||||
}
|
||||
|
||||
// Create .zip file (used by the auto-updater)
|
||||
var zipPath = path.join(buildPath[0], BUILD_NAME + '.zip')
|
||||
cp.execSync(`zip -r -y ${zipPath} ${appPath}`)
|
||||
function pack (cb) {
|
||||
packageZip() // always produce .zip file, used for automatic updates
|
||||
|
||||
// Create a .dmg (OS X disk image) file, for easy user installation.
|
||||
var dmgOpts = {
|
||||
basepath: config.ROOT_PATH,
|
||||
target: path.join(buildPath[0], BUILD_NAME + '.dmg'),
|
||||
specification: {
|
||||
title: config.APP_NAME,
|
||||
icon: config.APP_ICON + '.icns',
|
||||
background: path.join(config.STATIC_PATH, 'appdmg.png'),
|
||||
'icon-size': 128,
|
||||
contents: [
|
||||
{ x: 122, y: 240, type: 'file', path: appPath },
|
||||
{ x: 380, y: 240, type: 'link', path: '/Applications' },
|
||||
// Hide hidden icons out of view, for users who have hidden files shown.
|
||||
// https://github.com/LinusU/node-appdmg/issues/45#issuecomment-153924954
|
||||
{ x: 50, y: 500, type: 'position', path: '.background' },
|
||||
{ x: 100, y: 500, type: 'position', path: '.DS_Store' },
|
||||
{ x: 150, y: 500, type: 'position', path: '.Trashes' },
|
||||
{ x: 200, y: 500, type: 'position', path: '.VolumeIcon.icns' }
|
||||
]
|
||||
}
|
||||
if (argv.package === 'dmg' || argv.package === 'all') {
|
||||
packageDmg(cb)
|
||||
}
|
||||
}
|
||||
|
||||
function packageZip () {
|
||||
// Create .zip file (used by the auto-updater)
|
||||
console.log('OS X: Creating zip...')
|
||||
|
||||
var inPath = path.join(buildPath[0], config.APP_NAME + '.app')
|
||||
var outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip')
|
||||
zip.zipSync(inPath, outPath)
|
||||
|
||||
console.log('OS X: Created zip.')
|
||||
}
|
||||
|
||||
function packageDmg (cb) {
|
||||
console.log('OS X: Creating dmg...')
|
||||
|
||||
var appDmg = require('appdmg')
|
||||
|
||||
var targetPath = path.join(DIST_PATH, BUILD_NAME + '.dmg')
|
||||
rimraf.sync(targetPath)
|
||||
|
||||
// Create a .dmg (OS X disk image) file, for easy user installation.
|
||||
var dmgOpts = {
|
||||
basepath: config.ROOT_PATH,
|
||||
target: targetPath,
|
||||
specification: {
|
||||
title: config.APP_NAME,
|
||||
icon: config.APP_ICON + '.icns',
|
||||
background: path.join(config.STATIC_PATH, 'appdmg.png'),
|
||||
'icon-size': 128,
|
||||
contents: [
|
||||
{ x: 122, y: 240, type: 'file', path: appPath },
|
||||
{ x: 380, y: 240, type: 'link', path: '/Applications' },
|
||||
// Hide hidden icons out of view, for users who have hidden files shown.
|
||||
// https://github.com/LinusU/node-appdmg/issues/45#issuecomment-153924954
|
||||
{ x: 50, y: 500, type: 'position', path: '.background' },
|
||||
{ x: 100, y: 500, type: 'position', path: '.DS_Store' },
|
||||
{ x: 150, y: 500, type: 'position', path: '.Trashes' },
|
||||
{ x: 200, y: 500, type: 'position', path: '.VolumeIcon.icns' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
var dmg = appDmg(dmgOpts)
|
||||
dmg.on('error', cb)
|
||||
dmg.on('progress', function (info) {
|
||||
if (info.type === 'step-begin') console.log(info.title + '...')
|
||||
})
|
||||
dmg.on('finish', function (info) {
|
||||
cb(null, buildPath)
|
||||
})
|
||||
var dmg = appDmg(dmgOpts)
|
||||
dmg.once('error', cb)
|
||||
dmg.on('progress', function (info) {
|
||||
if (info.type === 'step-begin') console.log(info.title + '...')
|
||||
})
|
||||
dmg.once('finish', function (info) {
|
||||
console.log('OS X: Created dmg.')
|
||||
cb(null)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildWin32 (cb) {
|
||||
electronPackager(Object.assign({}, all, win32), cb)
|
||||
var installer = require('electron-winstaller')
|
||||
console.log('Windows: Packaging electron...')
|
||||
|
||||
/*
|
||||
* Path to folder with the following files:
|
||||
* - Windows Authenticode private key and cert (authenticode.p12)
|
||||
* - Windows Authenticode password file (authenticode.txt)
|
||||
*/
|
||||
var CERT_PATH
|
||||
try {
|
||||
fs.accessSync('D:')
|
||||
CERT_PATH = 'D:'
|
||||
} catch (err) {
|
||||
CERT_PATH = path.join(os.homedir(), 'Desktop')
|
||||
}
|
||||
|
||||
electronPackager(Object.assign({}, all, win32), function (err, buildPath) {
|
||||
if (err) return cb(err)
|
||||
console.log('Windows: Packaged electron. ' + buildPath)
|
||||
|
||||
var signWithParams
|
||||
if (process.platform === 'win32') {
|
||||
if (argv.sign) {
|
||||
var certificateFile = path.join(CERT_PATH, 'authenticode.p12')
|
||||
var certificatePassword = fs.readFileSync(path.join(CERT_PATH, 'authenticode.txt'), 'utf8')
|
||||
var timestampServer = 'http://timestamp.comodoca.com'
|
||||
signWithParams = `/a /f "${certificateFile}" /p "${certificatePassword}" /tr "${timestampServer}" /td sha256`
|
||||
} else {
|
||||
printWarning()
|
||||
}
|
||||
} else {
|
||||
printWarning()
|
||||
}
|
||||
|
||||
var tasks = []
|
||||
if (argv.package === 'exe' || argv.package === 'all') {
|
||||
tasks.push((cb) => packageInstaller(cb))
|
||||
}
|
||||
if (argv.package === 'portable' || argv.package === 'all') {
|
||||
tasks.push((cb) => packagePortable(cb))
|
||||
}
|
||||
series(tasks, cb)
|
||||
|
||||
function packageInstaller (cb) {
|
||||
console.log('Windows: Creating installer...')
|
||||
|
||||
installer.createWindowsInstaller({
|
||||
appDirectory: buildPath[0],
|
||||
authors: config.APP_TEAM,
|
||||
description: config.APP_NAME,
|
||||
exe: config.APP_NAME + '.exe',
|
||||
iconUrl: config.GITHUB_URL_RAW + '/static/' + config.APP_NAME + '.ico',
|
||||
loadingGif: path.join(config.STATIC_PATH, 'loading.gif'),
|
||||
name: config.APP_NAME,
|
||||
noMsi: true,
|
||||
outputDirectory: DIST_PATH,
|
||||
productName: config.APP_NAME,
|
||||
remoteReleases: config.GITHUB_URL,
|
||||
setupExe: config.APP_NAME + 'Setup-v' + config.APP_VERSION + '.exe',
|
||||
setupIcon: config.APP_ICON + '.ico',
|
||||
signWithParams: signWithParams,
|
||||
title: config.APP_NAME,
|
||||
usePackageJson: false,
|
||||
version: pkg.version
|
||||
})
|
||||
.then(function () {
|
||||
console.log('Windows: Created installer.')
|
||||
cb(null)
|
||||
})
|
||||
.catch(cb)
|
||||
}
|
||||
|
||||
function packagePortable (cb) {
|
||||
console.log('Windows: Creating portable app...')
|
||||
|
||||
var portablePath = path.join(buildPath[0], 'Portable Settings')
|
||||
mkdirp.sync(portablePath)
|
||||
|
||||
var inPath = path.join(DIST_PATH, path.basename(buildPath[0]))
|
||||
var outPath = path.join(DIST_PATH, BUILD_NAME + '-win.zip')
|
||||
zip.zipSync(inPath, outPath)
|
||||
|
||||
console.log('Windows: Created portable app.')
|
||||
cb(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildLinux (cb) {
|
||||
electronPackager(Object.assign({}, all, linux), cb)
|
||||
console.log('Linux: Packaging electron...')
|
||||
electronPackager(Object.assign({}, all, linux), function (err, buildPath) {
|
||||
if (err) return cb(err)
|
||||
console.log('Linux: Packaged electron. ' + buildPath)
|
||||
|
||||
var tasks = []
|
||||
buildPath.forEach(function (filesPath) {
|
||||
var destArch = filesPath.split('-').pop()
|
||||
|
||||
if (argv.package === 'deb' || argv.package === 'all') {
|
||||
tasks.push((cb) => packageDeb(filesPath, destArch, cb))
|
||||
}
|
||||
if (argv.package === 'zip' || argv.package === 'all') {
|
||||
tasks.push((cb) => packageZip(filesPath, destArch, cb))
|
||||
}
|
||||
})
|
||||
series(tasks, cb)
|
||||
})
|
||||
|
||||
function packageDeb (filesPath, destArch, cb) {
|
||||
// Create .deb file for Debian-based platforms
|
||||
console.log(`Linux: Creating ${destArch} deb...`)
|
||||
|
||||
var deb = require('nobin-debian-installer')()
|
||||
var destPath = path.join('/opt', pkg.name)
|
||||
|
||||
deb.pack({
|
||||
package: pkg,
|
||||
info: {
|
||||
arch: destArch === 'x64' ? 'amd64' : 'i386',
|
||||
targetDir: DIST_PATH,
|
||||
depends: 'libc6 (>= 2.4)',
|
||||
scripts: {
|
||||
postinst: path.join(config.STATIC_PATH, 'linux', 'postinst'),
|
||||
prerm: path.join(config.STATIC_PATH, 'linux', 'prerm')
|
||||
}
|
||||
}
|
||||
}, [{
|
||||
src: ['./**'],
|
||||
dest: destPath,
|
||||
expand: true,
|
||||
cwd: filesPath
|
||||
}], function (err) {
|
||||
if (err) return cb(err)
|
||||
console.log(`Linux: Created ${destArch} deb.`)
|
||||
cb(null)
|
||||
})
|
||||
}
|
||||
|
||||
function packageZip (filesPath, destArch, cb) {
|
||||
// Create .zip file for Linux
|
||||
console.log(`Linux: Creating ${destArch} zip...`)
|
||||
|
||||
var inPath = path.join(DIST_PATH, path.basename(filesPath))
|
||||
var outPath = path.join(DIST_PATH, BUILD_NAME + '-linux-' + destArch + '.zip')
|
||||
zip.zipSync(inPath, outPath)
|
||||
|
||||
console.log(`Linux: Created ${destArch} zip.`)
|
||||
cb(null)
|
||||
}
|
||||
}
|
||||
|
||||
function printDone (err, buildPath) {
|
||||
function printDone (err) {
|
||||
if (err) console.error(err.message || err)
|
||||
else console.log('Built ' + buildPath[0])
|
||||
}
|
||||
|
||||
/*
|
||||
* Print a large warning when signing is disabled so we are less likely to accidentally
|
||||
* ship unsigned binaries to users.
|
||||
*/
|
||||
function printWarning () {
|
||||
console.log(fs.readFileSync(path.join(__dirname, 'warning.txt'), 'utf8'))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
npm run update-authors
|
||||
git diff --exit-code
|
||||
npm run package
|
||||
npm run package -- --sign
|
||||
git push
|
||||
git push --tags
|
||||
gh-release
|
||||
npm publish
|
||||
./node_modules/.bin/gh-release
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
set -e
|
||||
|
||||
git pull
|
||||
npm run update-authors
|
||||
git diff --exit-code
|
||||
rm -rf node_modules/
|
||||
npm install
|
||||
npm dedupe
|
||||
npm test
|
||||
|
||||
@@ -10,7 +10,8 @@ while (<>) {
|
||||
next if $seen{$_};
|
||||
next if /<support\@greenkeeper.io>/;
|
||||
next if /<ungoldman\@gmail.com>/;
|
||||
next if /<grunjol\@users.noreply.github.com>/;
|
||||
next if /<dc\@DCs-MacBook.local>/;
|
||||
next if /<rolandoguedes\@gmail.com>/;
|
||||
$seen{$_} = push @authors, "- ", $_;
|
||||
}
|
||||
END {
|
||||
|
||||
12
bin/warning.txt
Normal file
12
bin/warning.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
*********************************************************
|
||||
_ _ ___ ______ _ _ _____ _ _ _____
|
||||
| | | |/ _ \ | ___ \ \ | |_ _| \ | | __ \
|
||||
| | | / /_\ \| |_/ / \| | | | | \| | | \/
|
||||
| |/\| | _ || /| . ` | | | | . ` | | __
|
||||
\ /\ / | | || |\ \| |\ |_| |_| |\ | |_\ \
|
||||
\/ \/\_| |_/\_| \_\_| \_/\___/\_| \_/\____/
|
||||
|
||||
Application is NOT signed. Do not ship this to users!
|
||||
|
||||
*********************************************************
|
||||
109
config.js
109
config.js
@@ -1,38 +1,113 @@
|
||||
var applicationConfigPath = require('application-config-path')
|
||||
var appConfig = require('application-config')('WebTorrent')
|
||||
var fs = require('fs')
|
||||
var path = require('path')
|
||||
|
||||
var APP_NAME = 'WebTorrent'
|
||||
var APP_TEAM = 'WebTorrent, LLC'
|
||||
var APP_VERSION = require('./package.json').version
|
||||
|
||||
var PORTABLE_PATH = path.join(path.dirname(process.execPath), 'Portable Settings')
|
||||
|
||||
module.exports = {
|
||||
APP_COPYRIGHT: 'Copyright © 2014-2016 The WebTorrent Project',
|
||||
ANNOUNCEMENT_URL: 'https://webtorrent.io/desktop/announcement',
|
||||
AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update',
|
||||
CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report',
|
||||
TELEMETRY_URL: 'https://webtorrent.io/desktop/telemetry',
|
||||
|
||||
APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM,
|
||||
APP_FILE_ICON: path.join(__dirname, 'static', 'WebTorrentFile'),
|
||||
APP_ICON: path.join(__dirname, 'static', 'WebTorrent'),
|
||||
APP_NAME: APP_NAME,
|
||||
APP_TEAM: APP_TEAM,
|
||||
APP_VERSION: APP_VERSION,
|
||||
APP_WINDOW_TITLE: APP_NAME + ' (BETA)',
|
||||
|
||||
AUTO_UPDATE_URL: 'https://webtorrent.io/app/update?version=' + APP_VERSION,
|
||||
AUTO_UPDATE_CHECK_STARTUP_DELAY: 10 * 1000 /* 10 seconds */,
|
||||
CONFIG_PATH: getConfigPath(),
|
||||
|
||||
CONFIG_PATH: applicationConfigPath(APP_NAME),
|
||||
CONFIG_POSTER_PATH: path.join(applicationConfigPath(APP_NAME), 'Posters'),
|
||||
CONFIG_TORRENT_PATH: path.join(applicationConfigPath(APP_NAME), 'Torrents'),
|
||||
DEFAULT_TORRENTS: [
|
||||
{
|
||||
name: 'Big Buck Bunny',
|
||||
posterFileName: 'bigBuckBunny.jpg',
|
||||
torrentFileName: 'bigBuckBunny.torrent'
|
||||
},
|
||||
{
|
||||
name: 'Cosmos Laundromat (Preview)',
|
||||
posterFileName: 'cosmosLaundromat.jpg',
|
||||
torrentFileName: 'cosmosLaundromat.torrent'
|
||||
},
|
||||
{
|
||||
name: 'Sintel',
|
||||
posterFileName: 'sintel.jpg',
|
||||
torrentFileName: 'sintel.torrent'
|
||||
},
|
||||
{
|
||||
name: 'Tears of Steel',
|
||||
posterFileName: 'tearsOfSteel.jpg',
|
||||
torrentFileName: 'tearsOfSteel.torrent'
|
||||
},
|
||||
{
|
||||
name: 'The WIRED CD - Rip. Sample. Mash. Share.',
|
||||
posterFileName: 'wiredCd.jpg',
|
||||
torrentFileName: 'wiredCd.torrent'
|
||||
}
|
||||
],
|
||||
|
||||
INDEX: 'file://' + path.join(__dirname, 'renderer', 'index.html'),
|
||||
DELAYED_INIT: 3000 /* 3 seconds */,
|
||||
|
||||
DEFAULT_DOWNLOAD_PATH: getDefaultDownloadPath(),
|
||||
|
||||
GITHUB_URL: 'https://github.com/feross/webtorrent-desktop',
|
||||
GITHUB_URL_ISSUES: 'https://github.com/feross/webtorrent-desktop/issues',
|
||||
GITHUB_URL_RAW: 'https://raw.githubusercontent.com/feross/webtorrent-desktop/master',
|
||||
|
||||
HOME_PAGE_URL: 'https://webtorrent.io',
|
||||
|
||||
IS_PORTABLE: isPortable(),
|
||||
IS_PRODUCTION: isProduction(),
|
||||
|
||||
POSTER_PATH: path.join(getConfigPath(), 'Posters'),
|
||||
ROOT_PATH: __dirname,
|
||||
STATIC_PATH: path.join(__dirname, 'static'),
|
||||
TORRENT_PATH: path.join(getConfigPath(), 'Torrents'),
|
||||
|
||||
SOUND_ADD: 'file://' + path.join(__dirname, 'static', 'sound', 'add.wav'),
|
||||
SOUND_DELETE: 'file://' + path.join(__dirname, 'static', 'sound', 'delete.wav'),
|
||||
SOUND_DISABLE: 'file://' + path.join(__dirname, 'static', 'sound', 'disable.wav'),
|
||||
SOUND_DONE: 'file://' + path.join(__dirname, 'static', 'sound', 'done.wav'),
|
||||
SOUND_ENABLE: 'file://' + path.join(__dirname, 'static', 'sound', 'enable.wav'),
|
||||
SOUND_ERROR: 'file://' + path.join(__dirname, 'static', 'sound', 'error.wav'),
|
||||
SOUND_PLAY: 'file://' + path.join(__dirname, 'static', 'sound', 'play.wav'),
|
||||
SOUND_STARTUP: 'file://' + path.join(__dirname, 'static', 'sound', 'startup.wav')
|
||||
WINDOW_ABOUT: 'file://' + path.join(__dirname, 'renderer', 'about.html'),
|
||||
WINDOW_MAIN: 'file://' + path.join(__dirname, 'renderer', 'main.html'),
|
||||
WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, 'renderer', 'webtorrent.html'),
|
||||
|
||||
WINDOW_MIN_HEIGHT: 38 + (120 * 2), // header height + 2 torrents
|
||||
WINDOW_MIN_WIDTH: 425
|
||||
}
|
||||
|
||||
function getConfigPath () {
|
||||
if (isPortable()) {
|
||||
return PORTABLE_PATH
|
||||
} else {
|
||||
return path.dirname(appConfig.filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultDownloadPath () {
|
||||
if (!process || !process.type) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (isPortable()) {
|
||||
return path.join(getConfigPath(), 'Downloads')
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
return process.type === 'renderer'
|
||||
? electron.remote.app.getPath('downloads')
|
||||
: electron.app.getPath('downloads')
|
||||
}
|
||||
|
||||
function isPortable () {
|
||||
try {
|
||||
return process.platform === 'win32' && isProduction() && !!fs.statSync(PORTABLE_PATH)
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isProduction () {
|
||||
@@ -40,7 +115,7 @@ function isProduction () {
|
||||
return false
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return !/\/Electron\.app\/Contents\/MacOS\/Electron$/.test(process.execPath)
|
||||
return !/\/Electron\.app\//.test(process.execPath)
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return !/\\electron\.exe$/.test(process.execPath)
|
||||
|
||||
14
crash-reporter.js
Normal file
14
crash-reporter.js
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
var config = require('./config')
|
||||
var electron = require('electron')
|
||||
|
||||
function init () {
|
||||
electron.crashReporter.start({
|
||||
companyName: config.APP_NAME,
|
||||
productName: config.APP_NAME,
|
||||
submitURL: config.CRASH_REPORT_URL
|
||||
})
|
||||
}
|
||||
57
main/announcement.js
Normal file
57
main/announcement.js
Normal file
@@ -0,0 +1,57 @@
|
||||
module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var config = require('../config')
|
||||
var log = require('./log')
|
||||
|
||||
var ANNOUNCEMENT_URL = config.ANNOUNCEMENT_URL +
|
||||
'?version=' + config.APP_VERSION +
|
||||
'&platform=' + process.platform
|
||||
|
||||
/**
|
||||
* In certain situations, the WebTorrent team may need to show an announcement to
|
||||
* all WebTorrent Desktop users. For example: a security notice, or an update
|
||||
* notification (if the auto-updater stops working).
|
||||
*
|
||||
* When there is an announcement, the `ANNOUNCEMENT_URL` endpoint should return an
|
||||
* HTTP 200 status code with a JSON object like this:
|
||||
*
|
||||
* {
|
||||
* "title": "WebTorrent Desktop Announcement",
|
||||
* "message": "Security Issue in v0.xx",
|
||||
* "detail": "Please update to v0.xx as soon as possible..."
|
||||
* }
|
||||
*/
|
||||
function init () {
|
||||
var get = require('simple-get')
|
||||
get.concat(ANNOUNCEMENT_URL, onResponse)
|
||||
}
|
||||
|
||||
function onResponse (err, res, data) {
|
||||
if (err) return log(`Failed to retrieve announcement: ${err.message}`)
|
||||
if (res.statusCode !== 200) return log('No announcement exists')
|
||||
|
||||
try {
|
||||
data = JSON.parse(data.toString())
|
||||
} catch (err) {
|
||||
// Support plaintext announcement messages, using a default title.
|
||||
data = {
|
||||
title: 'WebTorrent Desktop Announcement',
|
||||
message: data.toString(),
|
||||
detail: data.toString()
|
||||
}
|
||||
}
|
||||
|
||||
electron.dialog.showMessageBox({
|
||||
type: 'info',
|
||||
buttons: ['OK'],
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
detail: data.detail
|
||||
}, noop)
|
||||
}
|
||||
|
||||
function noop () {}
|
||||
@@ -1,31 +0,0 @@
|
||||
module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var config = require('../config')
|
||||
var log = require('./log')
|
||||
|
||||
var autoUpdater = electron.autoUpdater
|
||||
|
||||
function init () {
|
||||
autoUpdater.on('error', function (err) {
|
||||
log.error('App update error: ' + err.message || err)
|
||||
})
|
||||
|
||||
autoUpdater.setFeedURL(config.AUTO_UPDATE_URL)
|
||||
|
||||
/*
|
||||
* We always check for updates on app startup. To keep app startup fast, we delay this
|
||||
* first check so it happens when there is less going on.
|
||||
*/
|
||||
setTimeout(() => autoUpdater.checkForUpdates(), config.AUTO_UPDATE_CHECK_STARTUP_DELAY)
|
||||
|
||||
autoUpdater.on('checking-for-update', () => log('Checking for app update'))
|
||||
autoUpdater.on('update-available', () => log('App update available'))
|
||||
autoUpdater.on('update-not-available', () => log('App update not available'))
|
||||
autoUpdater.on('update-downloaded', function (e, releaseNotes, releaseName, releaseDate, updateURL) {
|
||||
log('App update downloaded: ', releaseName, updateURL)
|
||||
})
|
||||
}
|
||||
122
main/dialog.js
Normal file
122
main/dialog.js
Normal file
@@ -0,0 +1,122 @@
|
||||
module.exports = {
|
||||
openSeedFile,
|
||||
openSeedDirectory,
|
||||
openTorrentFile,
|
||||
openTorrentAddress,
|
||||
openFiles
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var config = require('../config')
|
||||
var log = require('./log')
|
||||
var windows = require('./windows')
|
||||
|
||||
/**
|
||||
* Show open dialog to create a single-file torrent.
|
||||
*/
|
||||
function openSeedFile () {
|
||||
if (!windows.main.win) return
|
||||
log('openSeedFile')
|
||||
var opts = {
|
||||
title: 'Select a file for the torrent.',
|
||||
properties: [ 'openFile' ]
|
||||
}
|
||||
setTitle(opts.title)
|
||||
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
|
||||
resetTitle()
|
||||
if (!Array.isArray(selectedPaths)) return
|
||||
windows.main.dispatch('showCreateTorrent', selectedPaths)
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Show open dialog to create a single-file or single-directory torrent. On
|
||||
* Windows and Linux, open dialogs are for files *or* directories only, not both,
|
||||
* so this function shows a directory dialog on those platforms.
|
||||
*/
|
||||
function openSeedDirectory () {
|
||||
if (!windows.main.win) return
|
||||
log('openSeedDirectory')
|
||||
var opts = process.platform === 'darwin'
|
||||
? {
|
||||
title: 'Select a file or folder for the torrent.',
|
||||
properties: [ 'openFile', 'openDirectory' ]
|
||||
}
|
||||
: {
|
||||
title: 'Select a folder for the torrent.',
|
||||
properties: [ 'openDirectory' ]
|
||||
}
|
||||
setTitle(opts.title)
|
||||
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
|
||||
resetTitle()
|
||||
if (!Array.isArray(selectedPaths)) return
|
||||
windows.main.dispatch('showCreateTorrent', selectedPaths)
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Show flexible open dialog that supports selecting .torrent files to add, or
|
||||
* a file or folder to create a single-file or single-directory torrent.
|
||||
*/
|
||||
function openFiles () {
|
||||
if (!windows.main.win) return
|
||||
log('openFiles')
|
||||
var opts = process.platform === 'darwin'
|
||||
? {
|
||||
title: 'Select a file or folder to add.',
|
||||
properties: [ 'openFile', 'openDirectory' ]
|
||||
}
|
||||
: {
|
||||
title: 'Select a file to add.',
|
||||
properties: [ 'openFile' ]
|
||||
}
|
||||
setTitle(opts.title)
|
||||
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
|
||||
resetTitle()
|
||||
if (!Array.isArray(selectedPaths)) return
|
||||
windows.main.dispatch('onOpen', selectedPaths)
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Show open dialog to open a .torrent file.
|
||||
*/
|
||||
function openTorrentFile () {
|
||||
if (!windows.main.win) return
|
||||
log('openTorrentFile')
|
||||
var opts = {
|
||||
title: 'Select a .torrent file.',
|
||||
filters: [{ name: 'Torrent Files', extensions: ['torrent'] }],
|
||||
properties: [ 'openFile', 'multiSelections' ]
|
||||
}
|
||||
setTitle(opts.title)
|
||||
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
|
||||
resetTitle()
|
||||
if (!Array.isArray(selectedPaths)) return
|
||||
selectedPaths.forEach(function (selectedPath) {
|
||||
windows.main.dispatch('addTorrent', selectedPath)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Show modal dialog to open a torrent URL (magnet uri, http torrent link, etc.)
|
||||
*/
|
||||
function openTorrentAddress () {
|
||||
log('openTorrentAddress')
|
||||
windows.main.dispatch('openTorrentAddress')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialogs on do not show a title on OS X, so the window title is used instead.
|
||||
*/
|
||||
function setTitle (title) {
|
||||
if (process.platform === 'darwin') {
|
||||
windows.main.dispatch('setTitle', title)
|
||||
}
|
||||
}
|
||||
|
||||
function resetTitle () {
|
||||
setTitle(config.APP_WINDOW_TITLE)
|
||||
}
|
||||
59
main/dock.js
Normal file
59
main/dock.js
Normal file
@@ -0,0 +1,59 @@
|
||||
module.exports = {
|
||||
downloadFinished,
|
||||
init,
|
||||
setBadge
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var app = electron.app
|
||||
|
||||
var dialog = require('./dialog')
|
||||
var log = require('./log')
|
||||
|
||||
/**
|
||||
* Add a right-click menu to the dock icon. (OS X)
|
||||
*/
|
||||
function init () {
|
||||
if (!app.dock) return
|
||||
var menu = electron.Menu.buildFromTemplate(getMenuTemplate())
|
||||
app.dock.setMenu(menu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounce the Downloads stack if `path` is inside the Downloads folder. (OS X)
|
||||
*/
|
||||
function downloadFinished (path) {
|
||||
if (!app.dock) return
|
||||
log(`downloadFinished: ${path}`)
|
||||
app.dock.downloadFinished(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display string in dock badging area. (OS X)
|
||||
*/
|
||||
function setBadge (text) {
|
||||
if (!app.dock) return
|
||||
log(`setBadge: ${text}`)
|
||||
app.dock.setBadge(String(text))
|
||||
}
|
||||
|
||||
function getMenuTemplate () {
|
||||
return [
|
||||
{
|
||||
label: 'Create New Torrent...',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => dialog.openSeedDirectory()
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent File...',
|
||||
accelerator: 'CmdOrCtrl+O',
|
||||
click: () => dialog.openTorrentFile()
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent Address...',
|
||||
accelerator: 'CmdOrCtrl+U',
|
||||
click: () => dialog.openTorrentAddress()
|
||||
}
|
||||
]
|
||||
}
|
||||
362
main/handlers.js
Normal file
362
main/handlers.js
Normal file
@@ -0,0 +1,362 @@
|
||||
module.exports = {
|
||||
install,
|
||||
uninstall
|
||||
}
|
||||
|
||||
var config = require('../config')
|
||||
var path = require('path')
|
||||
|
||||
function install () {
|
||||
if (process.platform === 'darwin') {
|
||||
installDarwin()
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
installWin32()
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
installLinux()
|
||||
}
|
||||
}
|
||||
|
||||
function uninstall () {
|
||||
if (process.platform === 'darwin') {
|
||||
uninstallDarwin()
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
uninstallWin32()
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
uninstallLinux()
|
||||
}
|
||||
}
|
||||
|
||||
function installDarwin () {
|
||||
var electron = require('electron')
|
||||
var app = electron.app
|
||||
|
||||
// On OS X, only protocols that are listed in `Info.plist` can be set as the
|
||||
// default handler at runtime.
|
||||
app.setAsDefaultProtocolClient('magnet')
|
||||
app.setAsDefaultProtocolClient('stream-magnet')
|
||||
|
||||
// File handlers are defined in `Info.plist`.
|
||||
}
|
||||
|
||||
function uninstallDarwin () {}
|
||||
|
||||
var EXEC_COMMAND = [ process.execPath ]
|
||||
|
||||
if (!config.IS_PRODUCTION) {
|
||||
EXEC_COMMAND.push(config.ROOT_PATH)
|
||||
}
|
||||
|
||||
function installWin32 () {
|
||||
var Registry = require('winreg')
|
||||
|
||||
var log = require('./log')
|
||||
|
||||
var iconPath = path.join(
|
||||
process.resourcesPath, 'app.asar.unpacked', 'static', 'WebTorrentFile.ico'
|
||||
)
|
||||
registerProtocolHandlerWin32(
|
||||
'magnet',
|
||||
'URL:BitTorrent Magnet URL',
|
||||
iconPath,
|
||||
EXEC_COMMAND
|
||||
)
|
||||
registerProtocolHandlerWin32(
|
||||
'stream-magnet',
|
||||
'URL:BitTorrent Stream-Magnet URL',
|
||||
iconPath,
|
||||
EXEC_COMMAND
|
||||
)
|
||||
registerFileHandlerWin32(
|
||||
'.torrent',
|
||||
'io.webtorrent.torrent',
|
||||
'BitTorrent Document',
|
||||
iconPath,
|
||||
EXEC_COMMAND
|
||||
)
|
||||
|
||||
/**
|
||||
* To add a protocol handler, the following keys must be added to the Windows registry:
|
||||
*
|
||||
* HKEY_CLASSES_ROOT
|
||||
* $PROTOCOL
|
||||
* (Default) = "$NAME"
|
||||
* URL Protocol = ""
|
||||
* DefaultIcon
|
||||
* (Default) = "$ICON"
|
||||
* shell
|
||||
* open
|
||||
* command
|
||||
* (Default) = "$COMMAND" "%1"
|
||||
*
|
||||
* Source: https://msdn.microsoft.com/en-us/library/aa767914.aspx
|
||||
*
|
||||
* However, the "HKEY_CLASSES_ROOT" key can only be written by the Administrator user.
|
||||
* So, we instead write to "HKEY_CURRENT_USER\Software\Classes", which is inherited by
|
||||
* "HKEY_CLASSES_ROOT" anyway, and can be written by unprivileged users.
|
||||
*/
|
||||
|
||||
function registerProtocolHandlerWin32 (protocol, name, icon, command) {
|
||||
var protocolKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: '\\Software\\Classes\\' + protocol
|
||||
})
|
||||
|
||||
setProtocol()
|
||||
|
||||
function setProtocol (err) {
|
||||
if (err) log.error(err.message)
|
||||
protocolKey.set('', Registry.REG_SZ, name, setURLProtocol)
|
||||
}
|
||||
|
||||
function setURLProtocol (err) {
|
||||
if (err) log.error(err.message)
|
||||
protocolKey.set('URL Protocol', Registry.REG_SZ, '', setIcon)
|
||||
}
|
||||
|
||||
function setIcon (err) {
|
||||
if (err) log.error(err.message)
|
||||
|
||||
var iconKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + protocol + '\\DefaultIcon'
|
||||
})
|
||||
iconKey.set('', Registry.REG_SZ, icon, setCommand)
|
||||
}
|
||||
|
||||
function setCommand (err) {
|
||||
if (err) log.error(err.message)
|
||||
|
||||
var commandKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command'
|
||||
})
|
||||
commandKey.set('', Registry.REG_SZ, `${commandToArgs(command)} "%1"`, done)
|
||||
}
|
||||
|
||||
function done (err) {
|
||||
if (err) log.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* To add a file handler, the following keys must be added to the Windows registry:
|
||||
*
|
||||
* HKEY_CLASSES_ROOT
|
||||
* $EXTENSION
|
||||
* (Default) = "$EXTENSION_ID"
|
||||
* $EXTENSION_ID
|
||||
* (Default) = "$NAME"
|
||||
* DefaultIcon
|
||||
* (Default) = "$ICON"
|
||||
* shell
|
||||
* open
|
||||
* command
|
||||
* (Default) = "$COMMAND" "%1"
|
||||
*/
|
||||
function registerFileHandlerWin32 (ext, id, name, icon, command) {
|
||||
setExt()
|
||||
|
||||
function setExt () {
|
||||
var extKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: '\\Software\\Classes\\' + ext
|
||||
})
|
||||
extKey.set('', Registry.REG_SZ, id, setId)
|
||||
}
|
||||
|
||||
function setId (err) {
|
||||
if (err) log.error(err.message)
|
||||
|
||||
var idKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + id
|
||||
})
|
||||
idKey.set('', Registry.REG_SZ, name, setIcon)
|
||||
}
|
||||
|
||||
function setIcon (err) {
|
||||
if (err) log.error(err.message)
|
||||
|
||||
var iconKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + id + '\\DefaultIcon'
|
||||
})
|
||||
iconKey.set('', Registry.REG_SZ, icon, setCommand)
|
||||
}
|
||||
|
||||
function setCommand (err) {
|
||||
if (err) log.error(err.message)
|
||||
|
||||
var commandKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + id + '\\shell\\open\\command'
|
||||
})
|
||||
commandKey.set('', Registry.REG_SZ, `${commandToArgs(command)} "%1"`, done)
|
||||
}
|
||||
|
||||
function done (err) {
|
||||
if (err) log.error(err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallWin32 () {
|
||||
var Registry = require('winreg')
|
||||
|
||||
unregisterProtocolHandlerWin32('magnet', EXEC_COMMAND)
|
||||
unregisterProtocolHandlerWin32('stream-magnet', EXEC_COMMAND)
|
||||
unregisterFileHandlerWin32('.torrent', 'io.webtorrent.torrent', EXEC_COMMAND)
|
||||
|
||||
function unregisterProtocolHandlerWin32 (protocol, command) {
|
||||
getCommand()
|
||||
|
||||
function getCommand () {
|
||||
var commandKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command'
|
||||
})
|
||||
commandKey.get('', function (err, item) {
|
||||
if (!err && item.value.indexOf(commandToArgs(command)) >= 0) {
|
||||
destroyProtocol()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function destroyProtocol () {
|
||||
var protocolKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + protocol
|
||||
})
|
||||
protocolKey.destroy(function () {})
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterFileHandlerWin32 (ext, id, command) {
|
||||
eraseId()
|
||||
|
||||
function eraseId () {
|
||||
var idKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: '\\Software\\Classes\\' + id
|
||||
})
|
||||
idKey.destroy(getExt)
|
||||
}
|
||||
|
||||
function getExt () {
|
||||
var extKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + ext
|
||||
})
|
||||
extKey.get('', function (err, item) {
|
||||
if (!err && item.value === id) {
|
||||
destroyExt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function destroyExt () {
|
||||
var extKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: '\\Software\\Classes\\' + ext
|
||||
})
|
||||
extKey.destroy(function () {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function commandToArgs (command) {
|
||||
return command.map((arg) => `"${arg}"`).join(' ')
|
||||
}
|
||||
|
||||
function installLinux () {
|
||||
var fs = require('fs-extra')
|
||||
var os = require('os')
|
||||
var path = require('path')
|
||||
|
||||
var config = require('../config')
|
||||
var log = require('./log')
|
||||
|
||||
installDesktopFile()
|
||||
installIconFile()
|
||||
|
||||
function installDesktopFile () {
|
||||
var templatePath = path.join(
|
||||
config.STATIC_PATH, 'linux', 'webtorrent-desktop.desktop'
|
||||
)
|
||||
fs.readFile(templatePath, 'utf8', writeDesktopFile)
|
||||
}
|
||||
|
||||
function writeDesktopFile (err, desktopFile) {
|
||||
if (err) return log.error(err.message)
|
||||
|
||||
var appPath = config.IS_PRODUCTION
|
||||
? path.dirname(process.execPath)
|
||||
: config.ROOT_PATH
|
||||
|
||||
desktopFile = desktopFile.replace(/\$APP_NAME/g, config.APP_NAME)
|
||||
desktopFile = desktopFile.replace(/\$APP_PATH/g, appPath)
|
||||
desktopFile = desktopFile.replace(/\$EXEC_PATH/g, EXEC_COMMAND.join(' '))
|
||||
desktopFile = desktopFile.replace(/\$TRY_EXEC_PATH/g, process.execPath)
|
||||
|
||||
var desktopFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.local',
|
||||
'share',
|
||||
'applications',
|
||||
'webtorrent-desktop.desktop'
|
||||
)
|
||||
fs.mkdirp(path.dirname(desktopFilePath))
|
||||
fs.writeFile(desktopFilePath, desktopFile, function (err) {
|
||||
if (err) return log.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
function installIconFile () {
|
||||
var iconStaticPath = path.join(config.STATIC_PATH, 'WebTorrent.png')
|
||||
fs.readFile(iconStaticPath, writeIconFile)
|
||||
}
|
||||
|
||||
function writeIconFile (err, iconFile) {
|
||||
if (err) return log.error(err.message)
|
||||
|
||||
var iconFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.local',
|
||||
'share',
|
||||
'icons',
|
||||
'webtorrent-desktop.png'
|
||||
)
|
||||
fs.mkdirp(path.dirname(iconFilePath))
|
||||
fs.writeFile(iconFilePath, iconFile, function (err) {
|
||||
if (err) return log.error(err.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallLinux () {
|
||||
var os = require('os')
|
||||
var path = require('path')
|
||||
var fs = require('fs-extra')
|
||||
|
||||
var desktopFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.local',
|
||||
'share',
|
||||
'applications',
|
||||
'webtorrent-desktop.desktop'
|
||||
)
|
||||
fs.removeSync(desktopFilePath)
|
||||
|
||||
var iconFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.local',
|
||||
'share',
|
||||
'icons',
|
||||
'webtorrent-desktop.png'
|
||||
)
|
||||
fs.removeSync(iconFilePath)
|
||||
}
|
||||
208
main/index.js
208
main/index.js
@@ -1,108 +1,160 @@
|
||||
console.time('init')
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var app = electron.app
|
||||
var ipcMain = electron.ipcMain
|
||||
|
||||
var autoUpdater = require('./auto-updater')
|
||||
var announcement = require('./announcement')
|
||||
var config = require('../config')
|
||||
var crashReporter = require('../crash-reporter')
|
||||
var dialog = require('./dialog')
|
||||
var dock = require('./dock')
|
||||
var handlers = require('./handlers')
|
||||
var ipc = require('./ipc')
|
||||
var log = require('./log')
|
||||
var menu = require('./menu')
|
||||
var registerProtocolHandler = require('./register-handlers')
|
||||
var shortcuts = require('./shortcuts')
|
||||
var squirrelWin32 = require('./squirrel-win32')
|
||||
var tray = require('./tray')
|
||||
var updater = require('./updater')
|
||||
var windows = require('./windows')
|
||||
|
||||
// Prevent multiple instances of the app from running at the same time. New instances
|
||||
// signal this instance and exit.
|
||||
var shouldQuit = app.makeSingleInstance(function (newArgv) {
|
||||
newArgv = sliceArgv(newArgv)
|
||||
|
||||
if (app.ipcReady) {
|
||||
log('Second app instance attempted to open but was prevented')
|
||||
|
||||
newArgv.forEach(function (torrentId) {
|
||||
windows.main.send('dispatch', 'onOpen', torrentId)
|
||||
})
|
||||
|
||||
if (windows.main.isMinimized()) {
|
||||
windows.main.restore()
|
||||
}
|
||||
windows.main.focus()
|
||||
} else {
|
||||
argv.push(...newArgv)
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldQuit) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
var shouldQuit = false
|
||||
var argv = sliceArgv(process.argv)
|
||||
|
||||
app.on('open-file', onOpen)
|
||||
app.on('open-url', onOpen)
|
||||
app.on('will-finish-launching', function () {
|
||||
autoUpdater.init()
|
||||
setupCrashReporter()
|
||||
})
|
||||
if (process.platform === 'win32') {
|
||||
shouldQuit = squirrelWin32.handleEvent(argv[0])
|
||||
argv = argv.filter((arg) => arg.indexOf('--squirrel') === -1)
|
||||
}
|
||||
|
||||
app.ipcReady = false // main window has finished loading and IPC is ready
|
||||
app.isQuitting = false
|
||||
|
||||
app.on('ready', function () {
|
||||
menu.init()
|
||||
windows.createMainWindow()
|
||||
shortcuts.init()
|
||||
registerProtocolHandler()
|
||||
})
|
||||
|
||||
app.on('ipcReady', function () {
|
||||
log('IS_PRODUCTION:', config.IS_PRODUCTION)
|
||||
if (argv.length) {
|
||||
log('command line args:', process.argv)
|
||||
}
|
||||
argv.forEach(function (torrentId) {
|
||||
windows.main.send('dispatch', 'onOpen', torrentId)
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', function () {
|
||||
app.isQuitting = true
|
||||
})
|
||||
|
||||
app.on('activate', function () {
|
||||
if (windows.main) {
|
||||
windows.main.show()
|
||||
} else {
|
||||
windows.createMainWindow(menu)
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', function () {
|
||||
if (process.platform !== 'darwin') {
|
||||
if (!shouldQuit) {
|
||||
// Prevent multiple instances of app from running at same time. New instances signal
|
||||
// this instance and quit.
|
||||
shouldQuit = app.makeSingleInstance(onAppOpen)
|
||||
if (shouldQuit) {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ipc.init()
|
||||
if (!shouldQuit) {
|
||||
init()
|
||||
}
|
||||
|
||||
function init () {
|
||||
if (config.IS_PORTABLE) {
|
||||
app.setPath('userData', config.CONFIG_PATH)
|
||||
}
|
||||
|
||||
var isReady = false // app ready, windows can be created
|
||||
app.ipcReady = false // main window has finished loading and IPC is ready
|
||||
app.isQuitting = false
|
||||
|
||||
// Open handlers must be added as early as possible
|
||||
app.on('open-file', onOpen)
|
||||
app.on('open-url', onOpen)
|
||||
|
||||
ipc.init()
|
||||
|
||||
app.once('will-finish-launching', function () {
|
||||
crashReporter.init()
|
||||
})
|
||||
|
||||
app.on('ready', function () {
|
||||
isReady = true
|
||||
|
||||
windows.main.init()
|
||||
windows.webtorrent.init()
|
||||
menu.init()
|
||||
|
||||
// To keep app startup fast, some code is delayed.
|
||||
setTimeout(delayedInit, config.DELAYED_INIT)
|
||||
|
||||
// Report uncaught exceptions
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error(err)
|
||||
var errJSON = {message: err.message, stack: err.stack}
|
||||
windows.main.dispatch('uncaughtError', 'main', errJSON)
|
||||
})
|
||||
})
|
||||
|
||||
app.once('ipcReady', function () {
|
||||
log('Command line args:', argv)
|
||||
processArgv(argv)
|
||||
console.timeEnd('init')
|
||||
})
|
||||
|
||||
app.on('before-quit', function (e) {
|
||||
if (app.isQuitting) return
|
||||
|
||||
app.isQuitting = true
|
||||
e.preventDefault()
|
||||
windows.main.dispatch('saveState') // try to save state on exit
|
||||
ipcMain.once('savedState', () => app.quit())
|
||||
setTimeout(() => app.quit(), 2000) // quit after 2 secs, at most
|
||||
})
|
||||
|
||||
app.on('activate', function () {
|
||||
if (isReady) windows.main.show()
|
||||
})
|
||||
}
|
||||
|
||||
function delayedInit () {
|
||||
announcement.init()
|
||||
dock.init()
|
||||
handlers.install()
|
||||
tray.init()
|
||||
updater.init()
|
||||
}
|
||||
|
||||
function onOpen (e, torrentId) {
|
||||
e.preventDefault()
|
||||
|
||||
if (app.ipcReady) {
|
||||
windows.main.send('dispatch', 'onOpen', torrentId)
|
||||
// Magnet links opened from Chrome won't focus the app without a setTimeout.
|
||||
// The confirmation dialog Chrome shows causes Chrome to steal back the focus.
|
||||
// Electron issue: https://github.com/atom/electron/issues/4338
|
||||
setTimeout(() => windows.main.show(), 100)
|
||||
|
||||
processArgv([ torrentId ])
|
||||
} else {
|
||||
argv.push(torrentId)
|
||||
}
|
||||
}
|
||||
|
||||
function onAppOpen (newArgv) {
|
||||
newArgv = sliceArgv(newArgv)
|
||||
|
||||
if (app.ipcReady) {
|
||||
log('Second app instance opened, but was prevented:', newArgv)
|
||||
windows.main.show()
|
||||
|
||||
processArgv(newArgv)
|
||||
} else {
|
||||
argv.push(...newArgv)
|
||||
}
|
||||
}
|
||||
|
||||
function sliceArgv (argv) {
|
||||
return argv.slice(config.IS_PRODUCTION ? 1 : 2)
|
||||
}
|
||||
|
||||
function setupCrashReporter () {
|
||||
// require('crash-reporter').start({
|
||||
// productName: 'WebTorrent',
|
||||
// companyName: 'WebTorrent',
|
||||
// submitURL: 'https://webtorrent.io/crash-report',
|
||||
// autoSubmit: true
|
||||
// })
|
||||
function processArgv (argv) {
|
||||
var torrentIds = []
|
||||
argv.forEach(function (arg) {
|
||||
if (arg === '-n') {
|
||||
dialog.openSeedDirectory()
|
||||
} else if (arg === '-o') {
|
||||
dialog.openTorrentFile()
|
||||
} else if (arg === '-u') {
|
||||
dialog.openTorrentAddress()
|
||||
} else if (arg.startsWith('-psn')) {
|
||||
// Ignore OS X launchd "process serial number" argument
|
||||
// Issue: https://github.com/feross/webtorrent-desktop/issues/214
|
||||
} else {
|
||||
torrentIds.push(arg)
|
||||
}
|
||||
})
|
||||
if (torrentIds.length > 0) {
|
||||
windows.main.dispatch('onOpen', torrentIds)
|
||||
}
|
||||
}
|
||||
|
||||
227
main/ipc.js
227
main/ipc.js
@@ -1,116 +1,179 @@
|
||||
module.exports = {
|
||||
init: init
|
||||
init
|
||||
}
|
||||
|
||||
var debug = require('debug')('webtorrent-app:ipcMain')
|
||||
var electron = require('electron')
|
||||
|
||||
var app = electron.app
|
||||
var ipcMain = electron.ipcMain
|
||||
var powerSaveBlocker = electron.powerSaveBlocker
|
||||
|
||||
var dialog = require('./dialog')
|
||||
var dock = require('./dock')
|
||||
var log = require('./log')
|
||||
var menu = require('./menu')
|
||||
var powerSaveBlocker = require('./power-save-blocker')
|
||||
var shell = require('./shell')
|
||||
var shortcuts = require('./shortcuts')
|
||||
var vlc = require('./vlc')
|
||||
var windows = require('./windows')
|
||||
var thumbnail = require('./thumbnail')
|
||||
|
||||
// has to be a number, not a boolean, and undefined throws an error
|
||||
var powerSaveBlockID = 0
|
||||
// Messages from the main process, to be sent once the WebTorrent process starts
|
||||
var messageQueueMainToWebTorrent = []
|
||||
|
||||
// holds a ChildProcess while we're playing a video in VLC, null otherwise
|
||||
var vlcProcess
|
||||
|
||||
function init () {
|
||||
ipcMain.on('ipcReady', function (e) {
|
||||
console.timeEnd('init')
|
||||
var ipc = electron.ipcMain
|
||||
|
||||
ipc.once('ipcReady', function (e) {
|
||||
app.ipcReady = true
|
||||
app.emit('ipcReady')
|
||||
})
|
||||
|
||||
ipcMain.on('showOpenTorrentFile', function (e) {
|
||||
menu.showOpenTorrentFile()
|
||||
ipc.once('ipcReadyWebTorrent', function (e) {
|
||||
app.ipcReadyWebTorrent = true
|
||||
log('sending %d queued messages from the main win to the webtorrent window',
|
||||
messageQueueMainToWebTorrent.length)
|
||||
messageQueueMainToWebTorrent.forEach(function (message) {
|
||||
windows.webtorrent.send(message.name, ...message.args)
|
||||
log('webtorrent: sent queued %s', message.name)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.on('setBounds', function (e, bounds, maximize) {
|
||||
setBounds(bounds, maximize)
|
||||
/**
|
||||
* Dialog
|
||||
*/
|
||||
|
||||
ipc.on('openTorrentFile', () => dialog.openTorrentFile())
|
||||
ipc.on('openFiles', () => dialog.openFiles())
|
||||
|
||||
/**
|
||||
* Dock
|
||||
*/
|
||||
|
||||
ipc.on('setBadge', (e, ...args) => dock.setBadge(...args))
|
||||
ipc.on('downloadFinished', (e, ...args) => dock.downloadFinished(...args))
|
||||
|
||||
/**
|
||||
* Events
|
||||
*/
|
||||
|
||||
ipc.on('onPlayerOpen', function () {
|
||||
menu.onPlayerOpen()
|
||||
shortcuts.onPlayerOpen()
|
||||
})
|
||||
|
||||
ipcMain.on('setAspectRatio', function (e, aspectRatio, extraSize) {
|
||||
setAspectRatio(aspectRatio, extraSize)
|
||||
ipc.on('onPlayerClose', function () {
|
||||
menu.onPlayerClose()
|
||||
shortcuts.onPlayerOpen()
|
||||
})
|
||||
|
||||
ipcMain.on('setBadge', function (e, text) {
|
||||
setBadge(text)
|
||||
ipc.on('updateThumbnailBar', function (e, isPaused) {
|
||||
thumbnail.updateThumbarButtons(isPaused)
|
||||
})
|
||||
|
||||
ipcMain.on('setProgress', function (e, progress) {
|
||||
setProgress(progress)
|
||||
/**
|
||||
* Power Save Blocker
|
||||
*/
|
||||
|
||||
ipc.on('blockPowerSave', () => powerSaveBlocker.start())
|
||||
ipc.on('unblockPowerSave', () => powerSaveBlocker.stop())
|
||||
|
||||
/**
|
||||
* Shell
|
||||
*/
|
||||
|
||||
ipc.on('openItem', (e, ...args) => shell.openItem(...args))
|
||||
ipc.on('showItemInFolder', (e, ...args) => shell.showItemInFolder(...args))
|
||||
ipc.on('moveItemToTrash', (e, ...args) => shell.moveItemToTrash(...args))
|
||||
|
||||
/**
|
||||
* Windows: Main
|
||||
*/
|
||||
|
||||
var main = windows.main
|
||||
|
||||
ipc.on('setAspectRatio', (e, ...args) => main.setAspectRatio(...args))
|
||||
ipc.on('setBounds', (e, ...args) => main.setBounds(...args))
|
||||
ipc.on('setProgress', (e, ...args) => main.setProgress(...args))
|
||||
ipc.on('setTitle', (e, ...args) => main.setTitle(...args))
|
||||
ipc.on('show', () => main.show())
|
||||
ipc.on('toggleFullScreen', (e, ...args) => main.toggleFullScreen(...args))
|
||||
|
||||
/**
|
||||
* VLC
|
||||
* TODO: Move most of this code to vlc.js
|
||||
*/
|
||||
|
||||
ipc.on('checkForVLC', function (e) {
|
||||
vlc.checkForVLC(function (isInstalled) {
|
||||
windows.main.send('checkForVLC', isInstalled)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.on('toggleFullScreen', function (e, flag) {
|
||||
menu.toggleFullScreen(flag)
|
||||
ipc.on('vlcPlay', function (e, url) {
|
||||
var args = ['--play-and-exit', '--video-on-top', '--no-video-title-show', '--quiet', url]
|
||||
log('Running vlc ' + args.join(' '))
|
||||
|
||||
vlc.spawn(args, function (err, proc) {
|
||||
if (err) return windows.main.dispatch('vlcNotFound')
|
||||
vlcProcess = proc
|
||||
|
||||
// If it works, close the modal after a second
|
||||
var closeModalTimeout = setTimeout(() =>
|
||||
windows.main.dispatch('exitModal'), 1000)
|
||||
|
||||
vlcProcess.on('close', function (code) {
|
||||
clearTimeout(closeModalTimeout)
|
||||
if (!vlcProcess) return // Killed
|
||||
log('VLC exited with code ', code)
|
||||
if (code === 0) {
|
||||
windows.main.dispatch('backToList')
|
||||
} else {
|
||||
windows.main.dispatch('vlcNotFound')
|
||||
}
|
||||
vlcProcess = null
|
||||
})
|
||||
|
||||
vlcProcess.on('error', function (e) {
|
||||
log('VLC error', e)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.on('setTitle', function (e, title) {
|
||||
windows.main.setTitle(title)
|
||||
ipc.on('vlcQuit', function () {
|
||||
if (!vlcProcess) return
|
||||
log('Killing VLC, pid ' + vlcProcess.pid)
|
||||
vlcProcess.kill('SIGKILL') // kill -9
|
||||
vlcProcess = null
|
||||
})
|
||||
|
||||
ipcMain.on('openItem', function (e, path) {
|
||||
log('opening file or folder: ' + path)
|
||||
electron.shell.openItem(path)
|
||||
})
|
||||
// Capture all events
|
||||
var oldEmit = ipc.emit
|
||||
ipc.emit = function (name, e, ...args) {
|
||||
// Relay messages between the main window and the WebTorrent hidden window
|
||||
if (name.startsWith('wt-') && !app.isQuitting) {
|
||||
if (e.sender.browserWindowOptions.title === 'webtorrent-hidden-window') {
|
||||
// Send message to main window
|
||||
windows.main.send(name, ...args)
|
||||
log('webtorrent: got %s', name)
|
||||
} else if (app.ipcReadyWebTorrent) {
|
||||
// Send message to webtorrent window
|
||||
windows.webtorrent.send(name, ...args)
|
||||
log('webtorrent: sent %s', name)
|
||||
} else {
|
||||
// Queue message for webtorrent window, it hasn't finished loading yet
|
||||
messageQueueMainToWebTorrent.push({
|
||||
name: name,
|
||||
args: args
|
||||
})
|
||||
log('webtorrent: queueing %s', name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ipcMain.on('blockPowerSave', blockPowerSave)
|
||||
ipcMain.on('unblockPowerSave', unblockPowerSave)
|
||||
}
|
||||
|
||||
function setBounds (bounds, maximize) {
|
||||
// Do nothing in fullscreen
|
||||
if (!windows.main || windows.main.isFullScreen()) return
|
||||
|
||||
// Maximize or minimize, if the second argument is present
|
||||
var willBeMaximized
|
||||
if (maximize === true) {
|
||||
if (!windows.main.isMaximized()) windows.main.maximize()
|
||||
willBeMaximized = true
|
||||
} else if (maximize === false) {
|
||||
if (windows.main.isMaximized()) windows.main.unmaximize()
|
||||
willBeMaximized = false
|
||||
} else {
|
||||
willBeMaximized = windows.main.isMaximized()
|
||||
}
|
||||
|
||||
// Assuming we're not maximized or maximizing, set the window size
|
||||
if (!willBeMaximized) {
|
||||
windows.main.setBounds(bounds, true)
|
||||
}
|
||||
}
|
||||
|
||||
function setAspectRatio (aspectRatio, extraSize) {
|
||||
debug('setAspectRatio %o %o', aspectRatio, extraSize)
|
||||
if (windows.main) {
|
||||
windows.main.setAspectRatio(aspectRatio, extraSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Display string in dock badging area (OS X)
|
||||
function setBadge (text) {
|
||||
debug('setBadge %s', text)
|
||||
if (app.dock) app.dock.setBadge(String(text))
|
||||
}
|
||||
|
||||
// Show progress bar. Valid range is [0, 1]. Remove when < 0; indeterminate when > 1.
|
||||
function setProgress (progress) {
|
||||
debug('setProgress %s', progress)
|
||||
if (windows.main) {
|
||||
windows.main.setProgressBar(progress)
|
||||
}
|
||||
}
|
||||
|
||||
function blockPowerSave () {
|
||||
powerSaveBlockID = powerSaveBlocker.start('prevent-display-sleep')
|
||||
debug('blockPowerSave %d', powerSaveBlockID)
|
||||
}
|
||||
|
||||
function unblockPowerSave () {
|
||||
if (powerSaveBlocker.isStarted(powerSaveBlockID)) {
|
||||
powerSaveBlocker.stop(powerSaveBlockID)
|
||||
debug('unblockPowerSave %d', powerSaveBlockID)
|
||||
// Emit all other events normally
|
||||
oldEmit.call(ipc, name, e, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ module.exports.error = error
|
||||
*/
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var windows = require('./windows')
|
||||
|
||||
var app = electron.app
|
||||
@@ -18,7 +17,7 @@ function log (...args) {
|
||||
if (app.ipcReady) {
|
||||
windows.main.send('log', ...args)
|
||||
} else {
|
||||
app.on('ipcReady', () => windows.main.send('log', ...args))
|
||||
app.once('ipcReady', () => windows.main.send('log', ...args))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +25,6 @@ function error (...args) {
|
||||
if (app.ipcReady) {
|
||||
windows.main.send('error', ...args)
|
||||
} else {
|
||||
app.on('ipcReady', () => windows.main.send('error', ...args))
|
||||
app.once('ipcReady', () => windows.main.send('error', ...args))
|
||||
}
|
||||
}
|
||||
|
||||
346
main/menu.js
346
main/menu.js
@@ -1,151 +1,112 @@
|
||||
module.exports = {
|
||||
init: init,
|
||||
onToggleFullScreen: onToggleFullScreen,
|
||||
onWindowHide: onWindowHide,
|
||||
onWindowShow: onWindowShow,
|
||||
showOpenTorrentFile: showOpenTorrentFile,
|
||||
toggleFullScreen: toggleFullScreen
|
||||
init,
|
||||
onPlayerClose,
|
||||
onPlayerOpen,
|
||||
onToggleAlwaysOnTop,
|
||||
onToggleFullScreen,
|
||||
onWindowBlur,
|
||||
onWindowFocus
|
||||
}
|
||||
|
||||
var debug = require('debug')('webtorrent-app:menu')
|
||||
var electron = require('electron')
|
||||
|
||||
var app = electron.app
|
||||
|
||||
var config = require('../config')
|
||||
var dialog = require('./dialog')
|
||||
var shell = require('./shell')
|
||||
var windows = require('./windows')
|
||||
var thumbnail = require('./thumbnail')
|
||||
|
||||
var appMenu, dockMenu
|
||||
var menu
|
||||
|
||||
function init () {
|
||||
appMenu = electron.Menu.buildFromTemplate(getAppMenuTemplate())
|
||||
electron.Menu.setApplicationMenu(appMenu)
|
||||
|
||||
dockMenu = electron.Menu.buildFromTemplate(getDockMenuTemplate())
|
||||
if (app.dock) app.dock.setMenu(dockMenu)
|
||||
menu = electron.Menu.buildFromTemplate(getMenuTemplate())
|
||||
electron.Menu.setApplicationMenu(menu)
|
||||
}
|
||||
|
||||
function toggleFullScreen (flag) {
|
||||
debug('toggleFullScreen %s', flag)
|
||||
if (windows.main && windows.main.isVisible()) {
|
||||
flag = flag != null ? flag : !windows.main.isFullScreen()
|
||||
windows.main.setFullScreen(flag)
|
||||
}
|
||||
function onPlayerClose () {
|
||||
getMenuItem('Play/Pause').enabled = false
|
||||
getMenuItem('Increase Volume').enabled = false
|
||||
getMenuItem('Decrease Volume').enabled = false
|
||||
getMenuItem('Step Forward').enabled = false
|
||||
getMenuItem('Step Backward').enabled = false
|
||||
getMenuItem('Increase Speed').enabled = false
|
||||
getMenuItem('Decrease Speed').enabled = false
|
||||
getMenuItem('Add Subtitles File...').enabled = false
|
||||
|
||||
thumbnail.showPlayerThumbnailBar()
|
||||
}
|
||||
|
||||
// Sets whether the window should always show on top of other windows
|
||||
function toggleFloatOnTop (flag) {
|
||||
debug('toggleFloatOnTop %s', flag)
|
||||
if (windows.main) {
|
||||
flag = flag != null ? flag : !windows.main.isAlwaysOnTop()
|
||||
windows.main.setAlwaysOnTop(flag)
|
||||
getMenuItem('Float on Top').checked = flag
|
||||
}
|
||||
function onPlayerOpen () {
|
||||
getMenuItem('Play/Pause').enabled = true
|
||||
getMenuItem('Increase Volume').enabled = true
|
||||
getMenuItem('Decrease Volume').enabled = true
|
||||
getMenuItem('Step Forward').enabled = true
|
||||
getMenuItem('Step Backward').enabled = true
|
||||
getMenuItem('Increase Speed').enabled = true
|
||||
getMenuItem('Decrease Speed').enabled = true
|
||||
getMenuItem('Add Subtitles File...').enabled = true
|
||||
|
||||
thumbnail.hidePlayerThumbnailBar()
|
||||
}
|
||||
|
||||
function toggleDevTools () {
|
||||
debug('toggleDevTools')
|
||||
if (windows.main) {
|
||||
windows.main.toggleDevTools()
|
||||
}
|
||||
function onToggleAlwaysOnTop (flag) {
|
||||
getMenuItem('Float on Top').checked = flag
|
||||
}
|
||||
|
||||
function reloadWindow () {
|
||||
debug('reloadWindow')
|
||||
if (windows.main) {
|
||||
windows.main.webContents.reloadIgnoringCache()
|
||||
}
|
||||
function onToggleFullScreen (flag) {
|
||||
getMenuItem('Full Screen').checked = flag
|
||||
}
|
||||
|
||||
function addFakeDevice (device) {
|
||||
debug('addFakeDevice %s', device)
|
||||
windows.main.send('addFakeDevice', device)
|
||||
}
|
||||
|
||||
function onWindowShow () {
|
||||
debug('onWindowShow')
|
||||
getMenuItem('Full Screen').enabled = true
|
||||
getMenuItem('Float on Top').enabled = true
|
||||
}
|
||||
|
||||
function onWindowHide () {
|
||||
debug('onWindowHide')
|
||||
function onWindowBlur () {
|
||||
getMenuItem('Full Screen').enabled = false
|
||||
getMenuItem('Float on Top').enabled = false
|
||||
}
|
||||
|
||||
function onToggleFullScreen (isFullScreen) {
|
||||
isFullScreen = isFullScreen != null ? isFullScreen : windows.main.isFullScreen()
|
||||
windows.main.setMenuBarVisibility(!isFullScreen)
|
||||
getMenuItem('Full Screen').checked = isFullScreen
|
||||
windows.main.send('fullscreenChanged', isFullScreen)
|
||||
function onWindowFocus () {
|
||||
getMenuItem('Full Screen').enabled = true
|
||||
getMenuItem('Float on Top').enabled = true
|
||||
}
|
||||
|
||||
function getMenuItem (label) {
|
||||
for (var i = 0; i < appMenu.items.length; i++) {
|
||||
var menuItem = appMenu.items[i].submenu.items.find(function (item) {
|
||||
for (var i = 0; i < menu.items.length; i++) {
|
||||
var menuItem = menu.items[i].submenu.items.find(function (item) {
|
||||
return item.label === label
|
||||
})
|
||||
if (menuItem) return menuItem
|
||||
}
|
||||
}
|
||||
|
||||
// Prompts the user for a file or folder, then makes a torrent out of the data
|
||||
function showCreateTorrent () {
|
||||
electron.dialog.showOpenDialog({
|
||||
title: 'Select a file or folder for the torrent file.',
|
||||
properties: [ 'openFile', 'openDirectory', 'multiSelections' ]
|
||||
}, function (filenames) {
|
||||
if (!Array.isArray(filenames)) return
|
||||
windows.main.send('dispatch', 'seed', filenames)
|
||||
})
|
||||
}
|
||||
|
||||
// Prompts the user to choose a torrent file, then adds it to the app
|
||||
function showOpenTorrentFile () {
|
||||
electron.dialog.showOpenDialog(windows.main, {
|
||||
title: 'Select a .torrent file to open.',
|
||||
properties: [ 'openFile', 'multiSelections' ]
|
||||
}, function (filenames) {
|
||||
if (!Array.isArray(filenames)) return
|
||||
filenames.forEach(function (filename) {
|
||||
windows.main.send('dispatch', 'addTorrent', filename)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Prompts the user for the URL of a torrent file, then downloads and adds it
|
||||
function showOpenTorrentAddress () {
|
||||
windows.main.send('showOpenTorrentAddress')
|
||||
}
|
||||
|
||||
function getAppMenuTemplate () {
|
||||
function getMenuTemplate () {
|
||||
var template = [
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Create New Torrent...',
|
||||
label: process.platform === 'darwin'
|
||||
? 'Create New Torrent...'
|
||||
: 'Create New Torrent from Folder...',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: showCreateTorrent
|
||||
click: () => dialog.openSeedDirectory()
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent File...',
|
||||
accelerator: 'CmdOrCtrl+O',
|
||||
click: showOpenTorrentFile
|
||||
click: () => dialog.openTorrentFile()
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent Address...',
|
||||
accelerator: 'CmdOrCtrl+U',
|
||||
click: showOpenTorrentAddress
|
||||
click: () => dialog.openTorrentAddress()
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: process.platform === 'darwin'
|
||||
? 'Close Window'
|
||||
: 'Close',
|
||||
label: process.platform === 'win32'
|
||||
? 'Close'
|
||||
: 'Close Window',
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
role: 'close'
|
||||
}
|
||||
@@ -173,6 +134,14 @@ function getAppMenuTemplate () {
|
||||
label: 'Select All',
|
||||
accelerator: 'CmdOrCtrl+A',
|
||||
role: 'selectall'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Preferences',
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: () => windows.main.dispatch('preferences')
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -185,12 +154,20 @@ function getAppMenuTemplate () {
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'Ctrl+Command+F'
|
||||
: 'F11',
|
||||
click: () => toggleFullScreen()
|
||||
click: () => windows.main.toggleFullScreen()
|
||||
},
|
||||
{
|
||||
label: 'Float on Top',
|
||||
type: 'checkbox',
|
||||
click: () => toggleFloatOnTop()
|
||||
click: () => windows.main.toggleAlwaysOnTop()
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Go Back',
|
||||
accelerator: 'Esc',
|
||||
click: () => windows.main.dispatch('escapeBack')
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
@@ -198,41 +175,89 @@ function getAppMenuTemplate () {
|
||||
{
|
||||
label: 'Developer',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'CmdOrCtrl+R',
|
||||
click: reloadWindow
|
||||
},
|
||||
{
|
||||
label: 'Developer Tools',
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'Alt+Command+I'
|
||||
: 'Ctrl+Shift+I',
|
||||
click: toggleDevTools
|
||||
click: () => windows.main.toggleDevTools()
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Add Fake Airplay',
|
||||
click: () => addFakeDevice('airplay')
|
||||
},
|
||||
{
|
||||
label: 'Add Fake Chromecast',
|
||||
click: () => addFakeDevice('chromecast')
|
||||
label: 'Show WebTorrent Process',
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'Alt+Command+P'
|
||||
: 'Ctrl+Shift+P',
|
||||
click: () => windows.webtorrent.toggleDevTools()
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
role: 'window',
|
||||
label: 'Playback',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Minimize',
|
||||
accelerator: 'CmdOrCtrl+M',
|
||||
role: 'minimize'
|
||||
label: 'Play/Pause',
|
||||
accelerator: 'Space',
|
||||
click: () => windows.main.dispatch('playPause'),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Increase Volume',
|
||||
accelerator: 'CmdOrCtrl+Up',
|
||||
click: () => windows.main.dispatch('changeVolume', 0.1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
label: 'Decrease Volume',
|
||||
accelerator: 'CmdOrCtrl+Down',
|
||||
click: () => windows.main.dispatch('changeVolume', -0.1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Step Forward',
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'CmdOrCtrl+Alt+Right'
|
||||
: 'Alt+Right',
|
||||
click: () => windows.main.dispatch('skip', 1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
label: 'Step Backward',
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'CmdOrCtrl+Alt+Left'
|
||||
: 'Alt+Left',
|
||||
click: () => windows.main.dispatch('skip', -1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Increase Speed',
|
||||
accelerator: 'CmdOrCtrl+=',
|
||||
click: () => windows.main.dispatch('changePlaybackRate', 1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
label: 'Decrease Speed',
|
||||
accelerator: 'CmdOrCtrl+-',
|
||||
click: () => windows.main.dispatch('changePlaybackRate', -1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Add Subtitles File...',
|
||||
click: () => windows.main.dispatch('openSubtitles'),
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -242,35 +267,43 @@ function getAppMenuTemplate () {
|
||||
submenu: [
|
||||
{
|
||||
label: 'Learn more about ' + config.APP_NAME,
|
||||
click: () => electron.shell.openExternal('https://webtorrent.io')
|
||||
click: () => shell.openExternal(config.HOME_PAGE_URL)
|
||||
},
|
||||
{
|
||||
label: 'Contribute on GitHub',
|
||||
click: () => electron.shell.openExternal('https://github.com/feross/webtorrent-app')
|
||||
click: () => shell.openExternal(config.GITHUB_URL)
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Report an Issue...',
|
||||
click: () => electron.shell.openExternal('https://github.com/feross/webtorrent-app/issues')
|
||||
click: () => shell.openExternal(config.GITHUB_URL_ISSUES)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
var name = app.getName()
|
||||
// Add WebTorrent app menu (OS X)
|
||||
template.unshift({
|
||||
label: name,
|
||||
label: config.APP_NAME,
|
||||
submenu: [
|
||||
{
|
||||
label: 'About ' + name,
|
||||
label: 'About ' + config.APP_NAME,
|
||||
role: 'about'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Preferences',
|
||||
accelerator: 'Cmd+,',
|
||||
click: () => windows.main.dispatch('preferences')
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Services',
|
||||
role: 'services',
|
||||
@@ -280,7 +313,7 @@ function getAppMenuTemplate () {
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Hide ' + name,
|
||||
label: 'Hide ' + config.APP_NAME,
|
||||
accelerator: 'Command+H',
|
||||
role: 'hide'
|
||||
},
|
||||
@@ -299,42 +332,61 @@ function getAppMenuTemplate () {
|
||||
{
|
||||
label: 'Quit',
|
||||
accelerator: 'Command+Q',
|
||||
click: function () { app.quit() }
|
||||
click: () => app.quit()
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Window menu
|
||||
// Add Window menu (OS X)
|
||||
template.splice(5, 0, {
|
||||
label: 'Window',
|
||||
role: 'window',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Minimize',
|
||||
accelerator: 'CmdOrCtrl+M',
|
||||
role: 'minimize'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Bring All to Front',
|
||||
role: 'front'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// On Windows and Linux, open dialogs do not support selecting both files and
|
||||
// folders and files, so add an extra menu item so there is one for each type.
|
||||
if (process.platform === 'linux' || process.platform === 'win32') {
|
||||
// File menu (Windows, Linux)
|
||||
template[0].submenu.unshift({
|
||||
label: 'Create New Torrent from File...',
|
||||
click: () => dialog.openSeedFile()
|
||||
})
|
||||
|
||||
// Help menu (Windows, Linux)
|
||||
template[4].submenu.push(
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Bring All to Front',
|
||||
role: 'front'
|
||||
label: 'About ' + config.APP_NAME,
|
||||
click: () => windows.about.init()
|
||||
}
|
||||
)
|
||||
}
|
||||
// Add "File > Quit" menu item so Linux distros where the system tray icon is
|
||||
// missing will have a way to quit the app.
|
||||
if (process.platform === 'linux') {
|
||||
// File menu (Linux)
|
||||
template[0].submenu.push({
|
||||
label: 'Quit',
|
||||
click: () => app.quit()
|
||||
})
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
function getDockMenuTemplate () {
|
||||
return [
|
||||
{
|
||||
label: 'Create New Torrent...',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: showCreateTorrent
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent File...',
|
||||
accelerator: 'CmdOrCtrl+O',
|
||||
click: showOpenTorrentFile
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent Address...',
|
||||
accelerator: 'CmdOrCtrl+U',
|
||||
click: showOpenTorrentAddress
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
30
main/power-save-blocker.js
Normal file
30
main/power-save-blocker.js
Normal file
@@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
start,
|
||||
stop
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
var log = require('./log')
|
||||
|
||||
var blockId = 0
|
||||
|
||||
/**
|
||||
* Block the system from entering low-power (sleep) mode or turning off the
|
||||
* display.
|
||||
*/
|
||||
function start () {
|
||||
stop() // Stop the previous power saver block, if one exists.
|
||||
blockId = electron.powerSaveBlocker.start('prevent-display-sleep')
|
||||
log(`powerSaveBlocker.start: ${blockId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop blocking the system from entering low-power mode.
|
||||
*/
|
||||
function stop () {
|
||||
if (!electron.powerSaveBlocker.isStarted(blockId)) {
|
||||
return
|
||||
}
|
||||
electron.powerSaveBlocker.stop(blockId)
|
||||
log(`powerSaveBlocker.stop: ${blockId}`)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
var log = require('./log')
|
||||
|
||||
module.exports = function () {
|
||||
if (process.platform === 'win32') {
|
||||
var path = require('path')
|
||||
var iconPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'static', 'WebTorrentFile.ico')
|
||||
registerProtocolHandlerWin32('magnet', 'URL:BitTorrent Magnet URL', iconPath, process.execPath)
|
||||
registerFileHandlerWin32('.torrent', 'io.webtorrent.torrent', 'BitTorrent Document', iconPath, process.execPath)
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
installDesktopFile()
|
||||
installDesktopIcon()
|
||||
}
|
||||
}
|
||||
|
||||
function installDesktopFile () {
|
||||
var config = require('../config')
|
||||
var fs = require('fs')
|
||||
var path = require('path')
|
||||
var os = require('os')
|
||||
|
||||
var templatePath = path.join(config.STATIC_PATH, 'webtorrent.desktop')
|
||||
var desktopFile = fs.readFileSync(templatePath, 'utf8')
|
||||
|
||||
desktopFile = desktopFile.replace(/\$APP_NAME/g, config.APP_NAME)
|
||||
desktopFile = desktopFile.replace(/\$APP_PATH/g, path.dirname(process.execPath))
|
||||
desktopFile = desktopFile.replace(/\$EXEC_PATH/g, process.execPath)
|
||||
|
||||
var desktopFilePath = path.join(os.homedir(), '.local', 'share', 'applications', 'webtorrent.desktop')
|
||||
fs.writeFileSync(desktopFilePath, desktopFile)
|
||||
}
|
||||
|
||||
function installDesktopIcon () {
|
||||
var config = require('../config')
|
||||
var fs = require('fs')
|
||||
var path = require('path')
|
||||
var os = require('os')
|
||||
|
||||
var iconStaticPath = path.join(config.STATIC_PATH, 'WebTorrent.png')
|
||||
var iconFile = fs.readFileSync(iconStaticPath)
|
||||
|
||||
var iconFilePath = path.join(os.homedir(), '.local', 'share', 'icons', 'webtorrent.png')
|
||||
fs.writeFileSync(iconFilePath, iconFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* To add a protocol handler on Windows, the following keys must be added to the Windows
|
||||
* registry:
|
||||
*
|
||||
* HKEY_CLASSES_ROOT
|
||||
* $PROTOCOL
|
||||
* (Default) = "$NAME"
|
||||
* URL Protocol = ""
|
||||
* DefaultIcon
|
||||
* (Default) = "$ICON"
|
||||
* shell
|
||||
* open
|
||||
* command
|
||||
* (Default) = "$COMMAND" "%1"
|
||||
*
|
||||
* Source: https://msdn.microsoft.com/en-us/library/aa767914.aspx
|
||||
*
|
||||
* However, the "HKEY_CLASSES_ROOT" key can only be written by the Administrator user.
|
||||
* So, we instead write to "HKEY_CURRENT_USER\Software\Classes", which is inherited by
|
||||
* "HKEY_CLASSES_ROOT" anyway, and can be written by unprivileged users.
|
||||
*/
|
||||
|
||||
function registerProtocolHandlerWin32 (protocol, name, icon, command) {
|
||||
var Registry = require('winreg')
|
||||
|
||||
var protocolKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: '\\Software\\Classes\\' + protocol
|
||||
})
|
||||
protocolKey.set('', Registry.REG_SZ, name, callback)
|
||||
protocolKey.set('URL Protocol', Registry.REG_SZ, '', callback)
|
||||
|
||||
var iconKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + protocol + '\\DefaultIcon'
|
||||
})
|
||||
iconKey.set('', Registry.REG_SZ, icon, callback)
|
||||
|
||||
var commandKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command'
|
||||
})
|
||||
commandKey.set('', Registry.REG_SZ, '"' + command + '" "%1"', callback)
|
||||
|
||||
function callback (err) {
|
||||
if (err) log.error(err.message || err)
|
||||
}
|
||||
}
|
||||
|
||||
function registerFileHandlerWin32 (ext, id, name, icon, command) {
|
||||
var Registry = require('winreg')
|
||||
|
||||
var extKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: '\\Software\\Classes\\' + ext
|
||||
})
|
||||
extKey.set('', Registry.REG_SZ, id, callback)
|
||||
|
||||
var idKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + id
|
||||
})
|
||||
idKey.set('', Registry.REG_SZ, name, callback)
|
||||
|
||||
var iconKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + id + '\\DefaultIcon'
|
||||
})
|
||||
iconKey.set('', Registry.REG_SZ, icon, callback)
|
||||
|
||||
var commandKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + id + '\\shell\\open\\command'
|
||||
})
|
||||
commandKey.set('', Registry.REG_SZ, '"' + command + '" "%1"', callback)
|
||||
|
||||
function callback (err) {
|
||||
if (err) log.error(err.message || err)
|
||||
}
|
||||
}
|
||||
41
main/shell.js
Normal file
41
main/shell.js
Normal file
@@ -0,0 +1,41 @@
|
||||
module.exports = {
|
||||
openExternal,
|
||||
openItem,
|
||||
showItemInFolder,
|
||||
moveItemToTrash
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
var log = require('./log')
|
||||
|
||||
/**
|
||||
* Open the given external protocol URL in the desktop’s default manner.
|
||||
*/
|
||||
function openExternal (url) {
|
||||
log(`openExternal: ${url}`)
|
||||
electron.shell.openExternal(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the given file in the desktop’s default manner.
|
||||
*/
|
||||
function openItem (path) {
|
||||
log(`openItem: ${path}`)
|
||||
electron.shell.openItem(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the given file in a file manager. If possible, select the file.
|
||||
*/
|
||||
function showItemInFolder (path) {
|
||||
log(`showItemInFolder: ${path}`)
|
||||
electron.shell.showItemInFolder(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given file to trash and returns a boolean status for the operation.
|
||||
*/
|
||||
function moveItemToTrash (path) {
|
||||
log(`moveItemToTrash: ${path}`)
|
||||
electron.shell.moveItemToTrash(path)
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
module.exports = {
|
||||
init: init
|
||||
onPlayerClose,
|
||||
onPlayerOpen
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
var localShortcut = require('electron-localshortcut')
|
||||
|
||||
var globalShortcut = electron.globalShortcut
|
||||
|
||||
var menu = require('./menu')
|
||||
var windows = require('./windows')
|
||||
|
||||
function init () {
|
||||
// Special "media key" for play/pause, available on some keyboards
|
||||
globalShortcut.register('MediaPlayPause', () => windows.main.send('dispatch', 'playPause'))
|
||||
|
||||
// ⌘+Shift+F is an alternative fullscreen shortcut to the ones defined in menu.js.
|
||||
// Electron does not support multiple accelerators for a single menu item, so this
|
||||
// is registered separately here.
|
||||
localShortcut.register('CmdOrCtrl+Shift+F', menu.toggleFullScreen)
|
||||
function onPlayerOpen () {
|
||||
// Register play/pause media key, available on some keyboards.
|
||||
electron.globalShortcut.register(
|
||||
'MediaPlayPause',
|
||||
() => windows.main.dispatch('playPause')
|
||||
)
|
||||
}
|
||||
|
||||
function onPlayerClose () {
|
||||
// Return the media key to the OS, so other apps can use it.
|
||||
electron.globalShortcut.unregister('MediaPlayPause')
|
||||
}
|
||||
|
||||
151
main/squirrel-win32.js
Normal file
151
main/squirrel-win32.js
Normal file
@@ -0,0 +1,151 @@
|
||||
module.exports = {
|
||||
handleEvent
|
||||
}
|
||||
|
||||
var cp = require('child_process')
|
||||
var electron = require('electron')
|
||||
var fs = require('fs')
|
||||
var os = require('os')
|
||||
var path = require('path')
|
||||
|
||||
var app = electron.app
|
||||
|
||||
var handlers = require('./handlers')
|
||||
|
||||
var EXE_NAME = path.basename(process.execPath)
|
||||
var UPDATE_EXE = path.join(process.execPath, '..', '..', 'Update.exe')
|
||||
|
||||
function handleEvent (cmd) {
|
||||
if (cmd === '--squirrel-install') {
|
||||
// App was installed. Install desktop/start menu shortcuts.
|
||||
createShortcuts(function () {
|
||||
// Ensure user sees install splash screen so they realize that Setup.exe actually
|
||||
// installed an application and isn't the application itself.
|
||||
setTimeout(function () {
|
||||
app.quit()
|
||||
}, 3000)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-updated') {
|
||||
// App was updated. (Called on new version of app)
|
||||
updateShortcuts(function () {
|
||||
app.quit()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-uninstall') {
|
||||
// App was just uninstalled. Undo anything we did in the --squirrel-install and
|
||||
// --squirrel-updated handlers
|
||||
|
||||
// Uninstall .torrent file and magnet link handlers
|
||||
handlers.uninstall()
|
||||
|
||||
// Remove desktop/start menu shortcuts.
|
||||
// HACK: add a callback to handlers.uninstall() so we can remove this setTimeout
|
||||
setTimeout(function () {
|
||||
removeShortcuts(function () {
|
||||
app.quit()
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-obsolete') {
|
||||
// App will be updated. (Called on outgoing version of app)
|
||||
app.quit()
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-firstrun') {
|
||||
// App is running for the first time. Do not quit, allow startup to continue.
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a command and invoke the callback when it completes with an error and
|
||||
* the output from standard out.
|
||||
*/
|
||||
function spawn (command, args, cb) {
|
||||
var stdout = ''
|
||||
|
||||
var child
|
||||
try {
|
||||
child = cp.spawn(command, args)
|
||||
} catch (err) {
|
||||
// Spawn can throw an error
|
||||
process.nextTick(function () {
|
||||
cb(error, stdout)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
child.stdout.on('data', function (data) {
|
||||
stdout += data
|
||||
})
|
||||
|
||||
var error = null
|
||||
child.on('error', function (processError) {
|
||||
error = processError
|
||||
})
|
||||
child.on('close', function (code, signal) {
|
||||
if (code !== 0 && !error) error = new Error('Command failed: #{signal || code}')
|
||||
if (error) error.stdout = stdout
|
||||
cb(error, stdout)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the Squirrel `Update.exe` command with the given arguments and invoke
|
||||
* the callback when the command completes.
|
||||
*/
|
||||
function spawnUpdate (args, cb) {
|
||||
spawn(UPDATE_EXE, args, cb)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create desktop and start menu shortcuts using the Squirrel `Update.exe`
|
||||
* command.
|
||||
*/
|
||||
function createShortcuts (cb) {
|
||||
spawnUpdate(['--createShortcut', EXE_NAME], cb)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update desktop and start menu shortcuts using the Squirrel `Update.exe`
|
||||
* command.
|
||||
*/
|
||||
function updateShortcuts (cb) {
|
||||
var homeDir = os.homedir()
|
||||
if (homeDir) {
|
||||
var desktopShortcutPath = path.join(homeDir, 'Desktop', 'WebTorrent.lnk')
|
||||
// If the desktop shortcut was deleted by the user, then keep it deleted.
|
||||
fs.access(desktopShortcutPath, function (err) {
|
||||
var desktopShortcutExists = !err
|
||||
createShortcuts(function () {
|
||||
if (desktopShortcutExists) {
|
||||
cb()
|
||||
} else {
|
||||
// Remove the unwanted desktop shortcut that was recreated
|
||||
fs.unlink(desktopShortcutPath, cb)
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
createShortcuts(cb)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove desktop and start menu shortcuts using the Squirrel `Update.exe`
|
||||
* command.
|
||||
*/
|
||||
function removeShortcuts (cb) {
|
||||
spawnUpdate(['--removeShortcut', EXE_NAME], cb)
|
||||
}
|
||||
35
main/thumbnail.js
Normal file
35
main/thumbnail.js
Normal file
@@ -0,0 +1,35 @@
|
||||
module.exports = {
|
||||
showPlayerThumbnailBar,
|
||||
hidePlayerThumbnailBar,
|
||||
updateThumbarButtons
|
||||
}
|
||||
|
||||
var path = require('path')
|
||||
var config = require('../config')
|
||||
|
||||
var windows = require('./windows')
|
||||
|
||||
// gets called on player open
|
||||
function showPlayerThumbnailBar () {
|
||||
updateThumbarButtons(false)
|
||||
}
|
||||
|
||||
// gets called on player close
|
||||
function hidePlayerThumbnailBar () {
|
||||
windows.main.win.setThumbarButtons([])
|
||||
}
|
||||
|
||||
function updateThumbarButtons (isPaused) {
|
||||
var icon = isPaused ? 'PlayThumbnailBarButton.png' : 'PauseThumbnailBarButton.png'
|
||||
var tooltip = isPaused ? 'Play' : 'Pause'
|
||||
var buttons = [
|
||||
{
|
||||
tooltip: tooltip,
|
||||
icon: path.join(config.STATIC_PATH, icon),
|
||||
click: function () {
|
||||
windows.main.send('dispatch', 'playPause')
|
||||
}
|
||||
}
|
||||
]
|
||||
windows.main.win.setThumbarButtons(buttons)
|
||||
}
|
||||
115
main/tray.js
Normal file
115
main/tray.js
Normal file
@@ -0,0 +1,115 @@
|
||||
module.exports = {
|
||||
hasTray,
|
||||
init,
|
||||
onWindowBlur,
|
||||
onWindowFocus
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var app = electron.app
|
||||
|
||||
var config = require('../config')
|
||||
var windows = require('./windows')
|
||||
|
||||
var tray
|
||||
|
||||
function init () {
|
||||
if (process.platform === 'linux') {
|
||||
initLinux()
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
initWin32()
|
||||
}
|
||||
// OS X apps generally do not have menu bar icons
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there a tray icon is active.
|
||||
*/
|
||||
function hasTray () {
|
||||
return !!tray
|
||||
}
|
||||
|
||||
function onWindowBlur () {
|
||||
if (!tray) return
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
function onWindowFocus () {
|
||||
if (!tray) return
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
function initLinux () {
|
||||
checkLinuxTraySupport(function (supportsTray) {
|
||||
if (supportsTray) createTray()
|
||||
})
|
||||
}
|
||||
|
||||
function initWin32 () {
|
||||
createTray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for libappindicator1 support before creating tray icon
|
||||
*/
|
||||
function checkLinuxTraySupport (cb) {
|
||||
var cp = require('child_process')
|
||||
|
||||
// Check that we're on Ubuntu (or another debian system) and that we have
|
||||
// libappindicator1. If WebTorrent was installed from the deb file, we should
|
||||
// always have it. If it was installed from the zip file, we might not.
|
||||
cp.exec('dpkg --get-selections libappindicator1', function (err, stdout) {
|
||||
if (err) return cb(false)
|
||||
// Unfortunately there's no cleaner way, as far as I can tell, to check
|
||||
// whether a debian package is installed:
|
||||
cb(stdout.endsWith('\tinstall\n'))
|
||||
})
|
||||
}
|
||||
|
||||
function createTray () {
|
||||
tray = new electron.Tray(getIconPath())
|
||||
|
||||
// On Windows, left click opens the app, right click opens the context menu.
|
||||
// On Linux, any click (left or right) opens the context menu.
|
||||
tray.on('click', () => windows.main.show())
|
||||
|
||||
// Show the tray context menu, and keep the available commands up to date
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
function updateTrayMenu () {
|
||||
var contextMenu = electron.Menu.buildFromTemplate(getMenuTemplate())
|
||||
tray.setContextMenu(contextMenu)
|
||||
}
|
||||
|
||||
function getMenuTemplate () {
|
||||
return [
|
||||
getToggleItem(),
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => app.quit()
|
||||
}
|
||||
]
|
||||
|
||||
function getToggleItem () {
|
||||
if (windows.main.win.isVisible()) {
|
||||
return {
|
||||
label: 'Hide to tray',
|
||||
click: () => windows.main.hide()
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
label: 'Show WebTorrent',
|
||||
click: () => windows.main.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getIconPath () {
|
||||
return process.platform === 'win32'
|
||||
? config.APP_ICON + '.ico'
|
||||
: config.APP_ICON + '.png'
|
||||
}
|
||||
76
main/updater.js
Normal file
76
main/updater.js
Normal file
@@ -0,0 +1,76 @@
|
||||
module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
var get = require('simple-get')
|
||||
|
||||
var config = require('../config')
|
||||
var log = require('./log')
|
||||
var windows = require('./windows')
|
||||
|
||||
var AUTO_UPDATE_URL = config.AUTO_UPDATE_URL +
|
||||
'?version=' + config.APP_VERSION +
|
||||
'&platform=' + process.platform
|
||||
|
||||
function init () {
|
||||
if (process.platform === 'linux') {
|
||||
initLinux()
|
||||
} else {
|
||||
initDarwinWin32()
|
||||
}
|
||||
}
|
||||
|
||||
// The Electron auto-updater does not support Linux yet, so manually check for
|
||||
// updates and show the user a modal notification.
|
||||
function initLinux () {
|
||||
get.concat(AUTO_UPDATE_URL, onResponse)
|
||||
}
|
||||
|
||||
function onResponse (err, res, data) {
|
||||
if (err) return log(`Update error: ${err.message}`)
|
||||
if (res.statusCode === 200) {
|
||||
// Update available
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch (err) {
|
||||
return log(`Update error: Invalid JSON response: ${err.message}`)
|
||||
}
|
||||
windows.main.dispatch('updateAvailable', data.version)
|
||||
} else if (res.statusCode === 204) {
|
||||
// No update available
|
||||
} else {
|
||||
// Unexpected status code
|
||||
log(`Update error: Unexpected status code: ${res.statusCode}`)
|
||||
}
|
||||
}
|
||||
|
||||
function initDarwinWin32 () {
|
||||
electron.autoUpdater.on(
|
||||
'error',
|
||||
(err) => log.error(`Update error: ${err.message}`)
|
||||
)
|
||||
|
||||
electron.autoUpdater.on(
|
||||
'checking-for-update',
|
||||
() => log('Checking for update')
|
||||
)
|
||||
|
||||
electron.autoUpdater.on(
|
||||
'update-available',
|
||||
() => log('Update available')
|
||||
)
|
||||
|
||||
electron.autoUpdater.on(
|
||||
'update-not-available',
|
||||
() => log('Update not available')
|
||||
)
|
||||
|
||||
electron.autoUpdater.on(
|
||||
'update-downloaded',
|
||||
(e, notes, name, date, url) => log(`Update downloaded: ${name}: ${url}`)
|
||||
)
|
||||
|
||||
electron.autoUpdater.setFeedURL(AUTO_UPDATE_URL)
|
||||
electron.autoUpdater.checkForUpdates()
|
||||
}
|
||||
22
main/vlc.js
Normal file
22
main/vlc.js
Normal file
@@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
checkForVLC,
|
||||
spawn
|
||||
}
|
||||
|
||||
var cp = require('child_process')
|
||||
var vlcCommand = require('vlc-command')
|
||||
|
||||
// Finds if VLC is installed on Mac, Windows, or Linux.
|
||||
// Calls back with true or false: whether VLC was detected
|
||||
function checkForVLC (cb) {
|
||||
vlcCommand((err) => cb(!err))
|
||||
}
|
||||
|
||||
// Spawns VLC with child_process.spawn() to return a ChildProcess object
|
||||
// Calls back with (err, childProcess)
|
||||
function spawn (args, cb) {
|
||||
vlcCommand(function (err, vlcPath) {
|
||||
if (err) return cb(err)
|
||||
cb(null, cp.spawn(vlcPath, args))
|
||||
})
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
var windows = module.exports = {
|
||||
main: null,
|
||||
createMainWindow: createMainWindow
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var app = electron.app
|
||||
|
||||
var config = require('../config')
|
||||
var menu = require('./menu')
|
||||
|
||||
function createMainWindow () {
|
||||
var win = windows.main = new electron.BrowserWindow({
|
||||
autoHideMenuBar: true, // Hide top menu bar unless Alt key is pressed (Windows, Linux)
|
||||
backgroundColor: '#282828',
|
||||
darkTheme: true, // Forces dark theme (GTK+3)
|
||||
icon: config.APP_ICON + '.png',
|
||||
minWidth: 375,
|
||||
minHeight: 38 + (120 * 2), // header height + 2 torrents
|
||||
show: false, // Hide window until DOM finishes loading
|
||||
title: config.APP_NAME,
|
||||
titleBarStyle: 'hidden-inset', // Hide OS chrome, except traffic light buttons (OS X)
|
||||
width: 450,
|
||||
height: 38 + (120 * 4) // header height + 4 torrents
|
||||
})
|
||||
win.loadURL(config.INDEX)
|
||||
|
||||
win.webContents.on('dom-ready', function () {
|
||||
menu.onToggleFullScreen()
|
||||
})
|
||||
|
||||
win.webContents.on('did-finish-load', function () {
|
||||
win.show()
|
||||
})
|
||||
|
||||
win.on('blur', menu.onWindowHide)
|
||||
win.on('focus', menu.onWindowShow)
|
||||
|
||||
win.on('enter-full-screen', () => menu.onToggleFullScreen(true))
|
||||
win.on('leave-full-screen', () => menu.onToggleFullScreen(false))
|
||||
|
||||
win.on('close', function (e) {
|
||||
if (process.platform === 'darwin' && !app.isQuitting) {
|
||||
e.preventDefault()
|
||||
win.send('dispatch', 'pause')
|
||||
win.hide()
|
||||
}
|
||||
})
|
||||
|
||||
win.once('closed', function () {
|
||||
windows.main = null
|
||||
})
|
||||
}
|
||||
48
main/windows/about.js
Normal file
48
main/windows/about.js
Normal file
@@ -0,0 +1,48 @@
|
||||
var about = module.exports = {
|
||||
init,
|
||||
win: null
|
||||
}
|
||||
|
||||
var config = require('../../config')
|
||||
var electron = require('electron')
|
||||
|
||||
function init () {
|
||||
if (about.win) {
|
||||
return about.win.show()
|
||||
}
|
||||
|
||||
var win = about.win = new electron.BrowserWindow({
|
||||
backgroundColor: '#ECECEC',
|
||||
center: true,
|
||||
fullscreen: false,
|
||||
height: 170,
|
||||
icon: getIconPath(),
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
resizable: false,
|
||||
show: false,
|
||||
skipTaskbar: true,
|
||||
title: 'About ' + config.APP_WINDOW_TITLE,
|
||||
useContentSize: true,
|
||||
width: 300
|
||||
})
|
||||
|
||||
win.loadURL(config.WINDOW_ABOUT)
|
||||
|
||||
// No menu on the About window
|
||||
win.setMenu(null)
|
||||
|
||||
win.webContents.once('did-finish-load', function () {
|
||||
win.show()
|
||||
})
|
||||
|
||||
win.once('closed', function () {
|
||||
about.win = null
|
||||
})
|
||||
}
|
||||
|
||||
function getIconPath () {
|
||||
return process.platform === 'win32'
|
||||
? config.APP_ICON + '.ico'
|
||||
: config.APP_ICON + '.png'
|
||||
}
|
||||
3
main/windows/index.js
Normal file
3
main/windows/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
exports.about = require('./about')
|
||||
exports.main = require('./main')
|
||||
exports.webtorrent = require('./webtorrent')
|
||||
220
main/windows/main.js
Normal file
220
main/windows/main.js
Normal file
@@ -0,0 +1,220 @@
|
||||
var main = module.exports = {
|
||||
dispatch,
|
||||
hide,
|
||||
init,
|
||||
send,
|
||||
setAspectRatio,
|
||||
setBounds,
|
||||
setProgress,
|
||||
setTitle,
|
||||
show,
|
||||
toggleAlwaysOnTop,
|
||||
toggleDevTools,
|
||||
toggleFullScreen,
|
||||
win: null
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var app = electron.app
|
||||
|
||||
var config = require('../../config')
|
||||
var log = require('../log')
|
||||
var menu = require('../menu')
|
||||
var tray = require('../tray')
|
||||
|
||||
var HEADER_HEIGHT = 37
|
||||
var TORRENT_HEIGHT = 100
|
||||
|
||||
function init () {
|
||||
if (main.win) {
|
||||
return main.win.show()
|
||||
}
|
||||
var win = main.win = new electron.BrowserWindow({
|
||||
backgroundColor: '#1E1E1E',
|
||||
darkTheme: true, // Forces dark theme (GTK+3)
|
||||
icon: getIconPath(), // Window icon (Windows, Linux)
|
||||
minWidth: config.WINDOW_MIN_WIDTH,
|
||||
minHeight: config.WINDOW_MIN_HEIGHT,
|
||||
title: config.APP_WINDOW_TITLE,
|
||||
titleBarStyle: 'hidden-inset', // Hide title bar (OS X)
|
||||
useContentSize: true, // Specify web page size without OS chrome
|
||||
width: 500,
|
||||
height: HEADER_HEIGHT + (TORRENT_HEIGHT * 6) // header height + 5 torrents
|
||||
})
|
||||
|
||||
win.loadURL(config.WINDOW_MAIN)
|
||||
|
||||
if (win.setSheetOffset) win.setSheetOffset(HEADER_HEIGHT)
|
||||
|
||||
win.webContents.on('dom-ready', function () {
|
||||
menu.onToggleFullScreen(main.win.isFullScreen())
|
||||
})
|
||||
|
||||
win.on('blur', onWindowBlur)
|
||||
win.on('focus', onWindowFocus)
|
||||
|
||||
win.on('hide', onWindowBlur)
|
||||
win.on('show', onWindowFocus)
|
||||
|
||||
win.on('enter-full-screen', function () {
|
||||
menu.onToggleFullScreen(true)
|
||||
send('fullscreenChanged', true)
|
||||
win.setMenuBarVisibility(false)
|
||||
})
|
||||
|
||||
win.on('leave-full-screen', function () {
|
||||
menu.onToggleFullScreen(false)
|
||||
send('fullscreenChanged', false)
|
||||
win.setMenuBarVisibility(true)
|
||||
})
|
||||
|
||||
win.on('close', function (e) {
|
||||
if (process.platform !== 'darwin' && !tray.hasTray()) {
|
||||
app.quit()
|
||||
} else if (!app.isQuitting) {
|
||||
e.preventDefault()
|
||||
hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function dispatch (...args) {
|
||||
send('dispatch', ...args)
|
||||
}
|
||||
|
||||
function hide () {
|
||||
if (!main.win) return
|
||||
main.win.send('dispatch', 'backToList')
|
||||
main.win.hide()
|
||||
}
|
||||
|
||||
function send (...args) {
|
||||
if (!main.win) return
|
||||
main.win.send(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce window aspect ratio. Remove with 0. (OS X)
|
||||
*/
|
||||
function setAspectRatio (aspectRatio) {
|
||||
if (!main.win) return
|
||||
main.win.setAspectRatio(aspectRatio)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the size of the window.
|
||||
* TODO: Clean this up? Seems overly complicated.
|
||||
*/
|
||||
function setBounds (bounds, maximize) {
|
||||
// Do nothing in fullscreen
|
||||
if (!main.win || main.win.isFullScreen()) {
|
||||
log('setBounds: not setting bounds because we\'re in full screen')
|
||||
return
|
||||
}
|
||||
|
||||
// Maximize or minimize, if the second argument is present
|
||||
var willBeMaximized
|
||||
if (maximize === true) {
|
||||
if (!main.win.isMaximized()) {
|
||||
log('setBounds: maximizing')
|
||||
main.win.maximize()
|
||||
}
|
||||
willBeMaximized = true
|
||||
} else if (maximize === false) {
|
||||
if (main.win.isMaximized()) {
|
||||
log('setBounds: unmaximizing')
|
||||
main.win.unmaximize()
|
||||
}
|
||||
willBeMaximized = false
|
||||
} else {
|
||||
willBeMaximized = main.win.isMaximized()
|
||||
}
|
||||
|
||||
// Assuming we're not maximized or maximizing, set the window size
|
||||
if (!willBeMaximized) {
|
||||
log('setBounds: setting bounds to ' + JSON.stringify(bounds))
|
||||
if (bounds.x === null && bounds.y === null) {
|
||||
// X and Y not specified? By default, center on current screen
|
||||
var scr = electron.screen.getDisplayMatching(main.win.getBounds())
|
||||
bounds.x = Math.round(scr.bounds.x + scr.bounds.width / 2 - bounds.width / 2)
|
||||
bounds.y = Math.round(scr.bounds.y + scr.bounds.height / 2 - bounds.height / 2)
|
||||
log('setBounds: centered to ' + JSON.stringify(bounds))
|
||||
}
|
||||
main.win.setBounds(bounds, true)
|
||||
} else {
|
||||
log('setBounds: not setting bounds because of window maximization')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set progress bar to [0, 1]. Indeterminate when > 1. Remove with < 0.
|
||||
*/
|
||||
function setProgress (progress) {
|
||||
if (!main.win) return
|
||||
main.win.setProgressBar(progress)
|
||||
}
|
||||
|
||||
function setTitle (title) {
|
||||
if (!main.win) return
|
||||
main.win.setTitle(title)
|
||||
}
|
||||
|
||||
function show () {
|
||||
if (!main.win) return
|
||||
main.win.show()
|
||||
}
|
||||
|
||||
// Sets whether the window should always show on top of other windows
|
||||
function toggleAlwaysOnTop (flag) {
|
||||
if (!main.win) return
|
||||
if (flag == null) {
|
||||
flag = !main.win.isAlwaysOnTop()
|
||||
}
|
||||
log(`toggleAlwaysOnTop ${flag}`)
|
||||
main.win.setAlwaysOnTop(flag)
|
||||
menu.onToggleAlwaysOnTop(flag)
|
||||
}
|
||||
|
||||
function toggleDevTools () {
|
||||
if (!main.win) return
|
||||
log('toggleDevTools')
|
||||
if (main.win.webContents.isDevToolsOpened()) {
|
||||
main.win.webContents.closeDevTools()
|
||||
} else {
|
||||
main.win.webContents.openDevTools({ detach: true })
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullScreen (flag) {
|
||||
if (!main.win || !main.win.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (flag == null) flag = !main.win.isFullScreen()
|
||||
|
||||
log(`toggleFullScreen ${flag}`)
|
||||
|
||||
if (flag) {
|
||||
// Fullscreen and aspect ratio do not play well together. (OS X)
|
||||
main.win.setAspectRatio(0)
|
||||
}
|
||||
|
||||
main.win.setFullScreen(flag)
|
||||
}
|
||||
|
||||
function onWindowBlur () {
|
||||
menu.onWindowBlur()
|
||||
tray.onWindowBlur()
|
||||
}
|
||||
|
||||
function onWindowFocus () {
|
||||
menu.onWindowFocus()
|
||||
tray.onWindowFocus()
|
||||
}
|
||||
|
||||
function getIconPath () {
|
||||
return process.platform === 'win32'
|
||||
? config.APP_ICON + '.ico'
|
||||
: config.APP_ICON + '.png'
|
||||
}
|
||||
62
main/windows/webtorrent.js
Normal file
62
main/windows/webtorrent.js
Normal file
@@ -0,0 +1,62 @@
|
||||
var webtorrent = module.exports = {
|
||||
init,
|
||||
send,
|
||||
show,
|
||||
toggleDevTools,
|
||||
win: null
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var config = require('../../config')
|
||||
var log = require('../log')
|
||||
|
||||
function init () {
|
||||
var win = webtorrent.win = new electron.BrowserWindow({
|
||||
backgroundColor: '#1E1E1E',
|
||||
center: true,
|
||||
fullscreen: false,
|
||||
fullscreenable: false,
|
||||
height: 150,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
resizable: false,
|
||||
show: false,
|
||||
skipTaskbar: true,
|
||||
title: 'webtorrent-hidden-window',
|
||||
useContentSize: true,
|
||||
width: 150
|
||||
})
|
||||
|
||||
win.loadURL(config.WINDOW_WEBTORRENT)
|
||||
|
||||
// Prevent killing the WebTorrent process
|
||||
win.on('close', function (e) {
|
||||
if (electron.app.isQuitting) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
win.hide()
|
||||
})
|
||||
}
|
||||
|
||||
function show () {
|
||||
if (!webtorrent.win) return
|
||||
webtorrent.win.show()
|
||||
}
|
||||
|
||||
function send (...args) {
|
||||
if (!webtorrent.win) return
|
||||
webtorrent.win.send(...args)
|
||||
}
|
||||
|
||||
function toggleDevTools () {
|
||||
if (!webtorrent.win) return
|
||||
log('toggleDevTools')
|
||||
if (webtorrent.win.webContents.isDevToolsOpened()) {
|
||||
webtorrent.win.webContents.closeDevTools()
|
||||
webtorrent.win.hide()
|
||||
} else {
|
||||
webtorrent.win.webContents.openDevTools({ detach: true })
|
||||
}
|
||||
}
|
||||
82
package.json
82
package.json
@@ -1,64 +1,90 @@
|
||||
{
|
||||
"name": "webtorrent-app",
|
||||
"name": "webtorrent-desktop",
|
||||
"description": "WebTorrent, the streaming torrent client. For OS X, Windows, and Linux.",
|
||||
"version": "0.0.1",
|
||||
"version": "0.8.1",
|
||||
"author": {
|
||||
"name": "Feross Aboukhadijeh",
|
||||
"email": "feross@feross.org",
|
||||
"url": "http://feross.org"
|
||||
"name": "WebTorrent, LLC",
|
||||
"email": "feross@webtorrent.io",
|
||||
"url": "https://webtorrent.io"
|
||||
},
|
||||
"bin": {
|
||||
"webtorrent-desktop": "./bin/cmd.js"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/feross/webtorrent-app/issues"
|
||||
"url": "https://github.com/feross/webtorrent-desktop/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"airplay-js": "guerrerocarlos/node-airplay-js",
|
||||
"application-config": "^0.2.0",
|
||||
"application-config-path": "^0.1.0",
|
||||
"airplayer": "^2.0.0",
|
||||
"application-config": "^0.2.1",
|
||||
"bitfield": "^1.0.2",
|
||||
"chromecasts": "^1.8.0",
|
||||
"create-torrent": "^3.22.1",
|
||||
"debug": "^2.2.0",
|
||||
"create-torrent": "^3.24.5",
|
||||
"deep-equal": "^1.0.1",
|
||||
"dlnacasts": "^0.1.0",
|
||||
"drag-drop": "^2.11.0",
|
||||
"electron-localshortcut": "^0.6.0",
|
||||
"electron-prebuilt": "1.2.1",
|
||||
"fs-extra": "^0.27.0",
|
||||
"hyperx": "^2.0.2",
|
||||
"iso-639-1": "^1.2.1",
|
||||
"languagedetect": "^1.1.1",
|
||||
"main-loop": "^3.2.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"musicmetadata": "^2.0.2",
|
||||
"network-address": "^1.1.0",
|
||||
"parse-torrent": "^5.7.3",
|
||||
"prettier-bytes": "^1.0.1",
|
||||
"upload-element": "^1.0.1",
|
||||
"run-parallel": "^1.1.6",
|
||||
"semver": "^5.1.0",
|
||||
"simple-concat": "^1.0.0",
|
||||
"simple-get": "^2.0.0",
|
||||
"srt-to-vtt": "^1.1.1",
|
||||
"virtual-dom": "^2.1.1",
|
||||
"webtorrent": "^0.86.0",
|
||||
"winreg": "^1.0.1"
|
||||
"vlc-command": "^1.0.1",
|
||||
"webtorrent": "0.x",
|
||||
"winreg": "^1.2.0",
|
||||
"zero-fill": "^2.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"appdmg": "^0.3.6",
|
||||
"cross-zip": "^2.0.1",
|
||||
"electron-osx-sign": "^0.3.0",
|
||||
"electron-packager": "^5.0.0",
|
||||
"electron-prebuilt": "0.37.2",
|
||||
"gh-release": "^2.0.2",
|
||||
"path-exists": "^2.1.0",
|
||||
"electron-packager": "^7.0.0",
|
||||
"electron-winstaller": "^2.3.0",
|
||||
"gh-release": "^2.0.3",
|
||||
"minimist": "^1.2.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"nobin-debian-installer": "^0.0.10",
|
||||
"open": "0.0.5",
|
||||
"plist": "^1.2.0",
|
||||
"rimraf": "^2.5.2",
|
||||
"standard": "^6.0.5"
|
||||
"run-series": "^1.1.4",
|
||||
"standard": "^7.0.0"
|
||||
},
|
||||
"homepage": "https://webtorrent.io",
|
||||
"keywords": [
|
||||
"desktop",
|
||||
"electron",
|
||||
"electron-app"
|
||||
"electron-app",
|
||||
"hybrid webtorrent client",
|
||||
"mad science",
|
||||
"torrent client",
|
||||
"torrent",
|
||||
"webtorrent"
|
||||
],
|
||||
"license": "MIT",
|
||||
"main": "index.js",
|
||||
"optionalDependencies": {
|
||||
"appdmg": "^0.4.3"
|
||||
},
|
||||
"productName": "WebTorrent",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/feross/webtorrent-app.git"
|
||||
"url": "git://github.com/feross/webtorrent-desktop.git"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "node ./bin/clean.js",
|
||||
"debug": "DEBUG=* electron .",
|
||||
"package": "npm prune && npm dedupe && node ./bin/package.js",
|
||||
"size": "npm run package -- --darwin && du -ch dist/WebTorrent-darwin-x64 | grep total",
|
||||
"open-config": "node ./bin/open-config.js",
|
||||
"package": "node ./bin/package.js",
|
||||
"start": "electron .",
|
||||
"test": "standard",
|
||||
"test": "standard && node ./bin/check-deps.js",
|
||||
"update-authors": "./bin/update-authors.sh"
|
||||
}
|
||||
}
|
||||
|
||||
38
renderer/about.html
Normal file
38
renderer/about.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
background-color: #ECECEC;
|
||||
font-family: BlinkMacSystemFont, 'Helvetica Neue', Helvetica, sans-serif;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
img {
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 0.9em;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
p {
|
||||
font-size: 0.8em;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="../static/WebTorrent.png">
|
||||
<h1>WebTorrent</h1>
|
||||
<p>
|
||||
Version <script>document.write(require('../package.json').version)</script>
|
||||
(<script>document.write(require('webtorrent/package.json').version)</script>)
|
||||
</p>
|
||||
<p><script>document.write(require('../config').APP_COPYRIGHT)</script></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,800 +0,0 @@
|
||||
/*
|
||||
* BASIC STYLES
|
||||
*/
|
||||
|
||||
*,
|
||||
*:after,
|
||||
*:before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: rgb(40, 40, 40);
|
||||
cursor: default;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #FFF;
|
||||
font-family: BlinkMacSystemFont, 'Helvetica Neue', Helvetica, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
background-color: rgb(40, 40, 40);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: rgb(40, 40, 40);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 1px solid rgb(40, 40, 40);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(to right, rgb(90, 90, 90), rgb(80, 80, 80))
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: rgb(40, 40, 40);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.loading .icon {
|
||||
font-size: 42px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
animation: spin-ccw 2s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spin-ccw {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
|
||||
.app {
|
||||
-webkit-user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
animation: fadein 0.3s;
|
||||
background: rgb(40, 40, 40);
|
||||
}
|
||||
|
||||
.app:not(.is-focused) {
|
||||
background: rgb(50, 50, 50);
|
||||
}
|
||||
|
||||
/*
|
||||
* MATERIAL ICONS
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Material Icons'),
|
||||
local('MaterialIcons-Regular'),
|
||||
url(../static/MaterialIcons-Regular.woff2) format('woff2');
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px; /* Preferred icon size */
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/*
|
||||
* UTILITY CLASSES
|
||||
*/
|
||||
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/*
|
||||
* BUTTONS
|
||||
*/
|
||||
|
||||
a,
|
||||
i {
|
||||
cursor: default;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
a:not(.disabled):hover,
|
||||
i:not(.disabled):hover {
|
||||
-webkit-filter: brightness(1.3);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
font-size: 22px;
|
||||
transition: all 0.1s ease-out;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* HEADER
|
||||
*/
|
||||
|
||||
.header {
|
||||
background: rgb(40, 40, 40);
|
||||
border-bottom: 1px solid rgb(20, 20, 20);
|
||||
height: 37px; /* vertically center OS menu buttons (OS X) */
|
||||
padding-top: 6px;
|
||||
overflow: hidden;
|
||||
flex: 0 1 auto;
|
||||
opacity: 1;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.15s ease-out;
|
||||
font-size: 14px;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.app:not(.is-focused) .header {
|
||||
background: rgb(50, 50, 50);
|
||||
}
|
||||
|
||||
.app.view-player .header {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.app.hide-video-controls.view-player .header {
|
||||
opacity: 0;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.app.hide-header .header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header .title {
|
||||
opacity: 0.6;
|
||||
position: absolute;
|
||||
margin-top: 1px;
|
||||
padding: 0 150px 0 150px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header .nav {
|
||||
font-weight: bold;
|
||||
margin-right: 9px;
|
||||
}
|
||||
|
||||
.header .nav.left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.app.is-darwin:not(.is-fullscreen) .header .nav.left {
|
||||
margin-left: 78px;
|
||||
}
|
||||
|
||||
.header .nav.right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.header .nav * {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header .nav .disabled {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.header .nav *:not(.disabled):hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.header .nav .back,
|
||||
.header .nav .forward {
|
||||
font-size: 30px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
/*
|
||||
* CONTENT
|
||||
*/
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: overlay;
|
||||
flex: 1 1 auto;
|
||||
margin-top: 37px;
|
||||
}
|
||||
|
||||
.app.view-player .content {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* MODAL POPOVERS
|
||||
*/
|
||||
|
||||
.modal .modal-background {
|
||||
content: ' ';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: black;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.modal .modal-content {
|
||||
position: fixed;
|
||||
top: 45px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
width: calc(100% - 20px);
|
||||
max-width: 600px;
|
||||
box-shadow: 2px 2px 10px 0px rgba(0,0,0,0.4);
|
||||
background-color: white;
|
||||
color: #222;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.open-torrent-address-modal input {
|
||||
width: calc(100% - 100px)
|
||||
}
|
||||
|
||||
/*
|
||||
* BUTTONS
|
||||
*/
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
margin-left: 10px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
color: #0cf;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
-webkit-filter: brightness(1.1);
|
||||
}
|
||||
|
||||
button:active {
|
||||
-webkit-filter: brightness(1.1);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* OTHER FORM ELEMENT DEFAULTS
|
||||
*/
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
width: 300px;
|
||||
padding: 6px;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 3px;
|
||||
box-shadow: 1px 1px 1px 0px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/*
|
||||
* PLAYER
|
||||
*/
|
||||
|
||||
.player {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.player .letterbox {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
.player video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* TORRENT LIST
|
||||
*/
|
||||
|
||||
.torrent {
|
||||
background: linear-gradient(to bottom right, #4B79A1, #283E51);
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background-position: 0 50%;
|
||||
transition: -webkit-filter 0.1s ease-out;
|
||||
position: relative;
|
||||
animation: fadein .4s;
|
||||
}
|
||||
|
||||
.torrent,
|
||||
.torrent-placeholder {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.torrent:not(:last-child) {
|
||||
border-bottom: 1px solid rgb(20, 20, 20);
|
||||
}
|
||||
|
||||
.torrent:hover {
|
||||
-webkit-filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.torrent .metadata {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
width: calc(100% - 150px);
|
||||
text-shadow: rgba(0, 0, 0, 0.5) 0 0 4px;
|
||||
}
|
||||
|
||||
.torrent .metadata span:not(:last-child)::after {
|
||||
content: ' — ';
|
||||
}
|
||||
|
||||
.torrent .buttons {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 10px;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.torrent:hover .buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.torrent .buttons > * {
|
||||
margin-left: 6px; /* space buttons apart */
|
||||
}
|
||||
|
||||
.torrent .buttons .download {
|
||||
background-color: #2233BB;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
font-size: 18px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.torrent .buttons .download.downloading {
|
||||
animation-name: greenpulse;
|
||||
animation-duration: 0.8s;
|
||||
animation-direction: alternate;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes greenpulse {
|
||||
0% {
|
||||
color: #ffffff;
|
||||
padding-top: 4px;
|
||||
}
|
||||
100% {
|
||||
color: #44dd44;
|
||||
padding-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.torrent .buttons .download.seeding {
|
||||
color: #44dd44;
|
||||
}
|
||||
|
||||
.torrent .buttons .play {
|
||||
padding-top: 10px;
|
||||
background-color: #F44336;
|
||||
}
|
||||
|
||||
.torrent.timeout .play,
|
||||
.torrent.unplayable .play {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.torrent.requested .play {
|
||||
border-top: 6px solid rgba(255, 255, 255, 0.2);
|
||||
border-right: 6px solid rgba(255, 255, 255, 0.2);
|
||||
border-bottom: 6px solid rgba(255, 255, 255, 0.2);
|
||||
border-left: 6px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
color: transparent;
|
||||
animation: load8 1.1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes load8 {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.torrent .buttons .delete {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.torrent .buttons .delete:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.torrent .name {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.torrent .status,
|
||||
.torrent .status2 {
|
||||
font-size: 1em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
* TORRENT LIST: DRAG-DROP TARGET
|
||||
*/
|
||||
|
||||
.torrent-placeholder {
|
||||
padding: 10px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.torrent-placeholder span {
|
||||
border: 5px #444 dashed;
|
||||
border-radius: 5px;
|
||||
color: #666;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body.drag .torrent-placeholder span {
|
||||
border-color: #def;
|
||||
color: #def;
|
||||
}
|
||||
|
||||
/*
|
||||
* TORRENT LIST: EXPANDED TORRENT DETAILS
|
||||
*/
|
||||
.torrent.selected {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.torrent-details {
|
||||
padding: 8em 20px 20px 20px;
|
||||
}
|
||||
|
||||
.torrent-details .open-folder {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.torrent-details table {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.torrent-details tr {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.torrent-details tr:hover,
|
||||
.torrent-details .open-folder:hover {
|
||||
background-color: rgba(200, 200, 200, 0.3);
|
||||
}
|
||||
|
||||
.torrent-details td {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.torrent-details td.col-icon {
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.torrent-details td.col-icon .icon {
|
||||
font-size: 18px;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.torrent-details td.col-name {
|
||||
width: auto;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.torrent-details td.col-progress {
|
||||
width: 4em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.torrent-details td.col-size {
|
||||
width: 4em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/*
|
||||
* PLAYER CONTROLS
|
||||
*/
|
||||
|
||||
.player-controls {
|
||||
position: fixed;
|
||||
background: rgba(40, 40, 40, 0.8);
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
bottom: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.app.hide-video-controls .player-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app.hide-video-controls .player {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.app.hide-video-controls .player .player-controls:hover {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* invisible click target for scrubbing */
|
||||
.player-controls .scrub-bar {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 23px; /* 3px .loading-bar plus 10px above and below */
|
||||
top: -10px;
|
||||
left: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.player-controls .loading-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
top: -3px;
|
||||
height: 3px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.1s ease-out;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.player-controls .loading-bar-part {
|
||||
position: absolute;
|
||||
background-color: #dd0000;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.player-controls .playback-cursor {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
background-color: #FFF;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
transition-property: width, height, border-radius, margin-top, margin-left;
|
||||
transition-duration: 0.1s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.player-controls .play-pause {
|
||||
display: block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 5px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.player-controls .chromecast,
|
||||
.player-controls .airplay,
|
||||
.player-controls .fullscreen,
|
||||
.player-controls .back {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.player-controls .back {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.player-controls .chromecast,
|
||||
.player-controls .airplay,
|
||||
.player-controls .fullscreen {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.player-controls .fullscreen {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.player-controls .chromecast,
|
||||
.player-controls .airplay {
|
||||
font-size: 18px; /* make the cast icons less huge */
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.player-controls .chromecast.active,
|
||||
.player-controls .airplay.active {
|
||||
color: #9af;
|
||||
}
|
||||
|
||||
.player .playback-bar:hover .loading-bar {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.player .playback-bar:hover .playback-cursor {
|
||||
top: -8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/*
|
||||
* CHROMECAST / AIRPLAY CONTROLS
|
||||
*/
|
||||
|
||||
.cast-screen {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
color: #eee;
|
||||
text-align: center;
|
||||
line-height: 2;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.cast-screen .icon {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
.cast-screen .cast-type,
|
||||
.cast-screen .cast-status {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.cast-screen .cast-type {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/*
|
||||
* ERRORS
|
||||
*/
|
||||
|
||||
.error-popover {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
top: 36px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app.hide-header .error-popover {
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.error-popover.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-popover .error,
|
||||
.error-popover .title {
|
||||
padding: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.error-popover .title {
|
||||
font-weight: bold;
|
||||
color: #c44;
|
||||
}
|
||||
|
||||
.error-popover .error {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.error-popover .error:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* MEDIA QUERIES
|
||||
*/
|
||||
|
||||
@media only screen and (min-width: 700px) {
|
||||
body {
|
||||
font-size: 16px;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.torrent,
|
||||
.torrent-placeholder {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 900px) {
|
||||
body {
|
||||
font-size: 18px;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.torrent,
|
||||
.torrent-placeholder {
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
@@ -1,820 +0,0 @@
|
||||
console.time('init')
|
||||
|
||||
var cfg = require('application-config')('WebTorrent')
|
||||
var createTorrent = require('create-torrent')
|
||||
var dragDrop = require('drag-drop')
|
||||
var electron = require('electron')
|
||||
var EventEmitter = require('events')
|
||||
var fs = require('fs')
|
||||
var mainLoop = require('main-loop')
|
||||
var mkdirp = require('mkdirp')
|
||||
var networkAddress = require('network-address')
|
||||
var path = require('path')
|
||||
var remote = require('remote')
|
||||
var WebTorrent = require('webtorrent')
|
||||
|
||||
var createElement = require('virtual-dom/create-element')
|
||||
var diff = require('virtual-dom/diff')
|
||||
var patch = require('virtual-dom/patch')
|
||||
|
||||
var App = require('./views/app')
|
||||
var Cast = require('./lib/cast')
|
||||
var errors = require('./lib/errors')
|
||||
var config = require('../config')
|
||||
var TorrentPlayer = require('./lib/torrent-player')
|
||||
var torrentPoster = require('./lib/torrent-poster')
|
||||
|
||||
// Electron apps have two processes: a main process (node) runs first and starts
|
||||
// a renderer process (essentially a Chrome window). We're in the renderer process,
|
||||
// and this IPC channel receives from and sends messages to the main process
|
||||
var ipcRenderer = electron.ipcRenderer
|
||||
var clipboard = electron.clipboard
|
||||
|
||||
// For easy debugging in Developer Tools
|
||||
var state = global.state = require('./state')
|
||||
|
||||
// Force use of webtorrent trackers on all torrents
|
||||
global.WEBTORRENT_ANNOUNCE = createTorrent.announceList
|
||||
.map((arr) => arr[0])
|
||||
.filter((url) => url.indexOf('wss://') === 0 || url.indexOf('ws://') === 0)
|
||||
|
||||
var vdomLoop
|
||||
|
||||
// All state lives in state.js. `state.saved` is read from and written to a file.
|
||||
// All other state is ephemeral. First we load state.saved then initialize the app.
|
||||
loadState(init)
|
||||
|
||||
/**
|
||||
* Called once when the application loads. (Not once per window.)
|
||||
* Connects to the torrent networks, sets up the UI and OS integrations like
|
||||
* the dock icon and drag+drop.
|
||||
*/
|
||||
function init () {
|
||||
state.location.go({ url: 'home' })
|
||||
|
||||
// Connect to the WebTorrent and BitTorrent networks
|
||||
// WebTorrent.app is a hybrid client, as explained here: https://webtorrent.io/faq
|
||||
state.client = new WebTorrent()
|
||||
state.client.on('warning', onWarning)
|
||||
state.client.on('error', function (err) {
|
||||
// TODO: WebTorrent should have semantic errors
|
||||
if (err.message.startsWith('There is already a swarm')) {
|
||||
onError(new Error('Couldn\'t add duplicate torrent'))
|
||||
} else {
|
||||
onError(err)
|
||||
}
|
||||
})
|
||||
resumeTorrents() /* restart everything we were torrenting last time the app ran */
|
||||
setInterval(updateTorrentProgress, 1000)
|
||||
|
||||
// The UI is built with virtual-dom, a minimalist library extracted from React
|
||||
// The concepts--one way data flow, a pure function that renders state to a
|
||||
// virtual DOM tree, and a diff that applies changes in the vdom to the real
|
||||
// DOM, are all the same. Learn more: https://facebook.github.io/react/
|
||||
vdomLoop = mainLoop(state, render, {
|
||||
create: createElement,
|
||||
diff: diff,
|
||||
patch: patch
|
||||
})
|
||||
document.body.appendChild(vdomLoop.target)
|
||||
|
||||
// Calling update() updates the UI given the current state
|
||||
// Do this at least once a second to show latest state for each torrent
|
||||
// (eg % downloaded) and to keep the cursor in sync when playing a video
|
||||
setInterval(function () {
|
||||
update()
|
||||
updateClientProgress()
|
||||
}, 1000)
|
||||
|
||||
window.addEventListener('beforeunload', saveState)
|
||||
|
||||
// listen for messages from the main process
|
||||
setupIpc()
|
||||
|
||||
// OS integrations:
|
||||
// ...Chromecast and Airplay
|
||||
Cast.init(update)
|
||||
|
||||
// ...drag and drop a torrent or video file to play or seed
|
||||
dragDrop('body', (files) => dispatch('onOpen', files))
|
||||
|
||||
// ...same thing if you paste a torrent
|
||||
document.addEventListener('paste', onPaste)
|
||||
|
||||
// ...keyboard shortcuts
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.which === 27) { /* ESC means either exit fullscreen or go back */
|
||||
if (state.modal) {
|
||||
dispatch('exitModal')
|
||||
} else if (state.window.isFullScreen) {
|
||||
dispatch('toggleFullScreen')
|
||||
} else {
|
||||
dispatch('back')
|
||||
}
|
||||
} else if (e.which === 32) { /* spacebar pauses or plays the video */
|
||||
dispatch('playPause')
|
||||
}
|
||||
})
|
||||
|
||||
// ...focus and blur. Needed to show correct dock icon text ("badge") in OSX
|
||||
window.addEventListener('focus', function () {
|
||||
state.window.isFocused = true
|
||||
state.dock.badge = 0
|
||||
update()
|
||||
})
|
||||
|
||||
window.addEventListener('blur', function () {
|
||||
state.window.isFocused = false
|
||||
update()
|
||||
})
|
||||
|
||||
// Done! Ideally we want to get here <100ms after the user clicks the app
|
||||
document.querySelector('.loading').remove() /* TODO: no spinner once fast enough */
|
||||
playInterfaceSound(config.SOUND_STARTUP)
|
||||
console.timeEnd('init')
|
||||
}
|
||||
|
||||
// This is the (mostly) pure function from state -> UI. Returns a virtual DOM
|
||||
// tree. Any events, such as button clicks, will turn into calls to dispatch()
|
||||
function render (state) {
|
||||
return App(state, dispatch)
|
||||
}
|
||||
|
||||
// Calls render() to go from state -> UI, then applies to vdom to the real DOM.
|
||||
function update () {
|
||||
vdomLoop.update(state)
|
||||
updateElectron()
|
||||
}
|
||||
|
||||
function updateElectron () {
|
||||
if (state.window.title !== state.prev.title) {
|
||||
state.prev.title = state.window.title
|
||||
ipcRenderer.send('setTitle', state.window.title)
|
||||
}
|
||||
if (state.dock.progress !== state.prev.progress) {
|
||||
state.prev.progress = state.dock.progress
|
||||
ipcRenderer.send('setProgress', state.dock.progress)
|
||||
}
|
||||
if (state.dock.badge !== state.prev.badge) {
|
||||
state.prev.badge = state.dock.badge
|
||||
ipcRenderer.send('setBadge', state.dock.badge || '')
|
||||
}
|
||||
}
|
||||
|
||||
// Events from the UI never modify state directly. Instead they call dispatch()
|
||||
function dispatch (action, ...args) {
|
||||
if (['videoMouseMoved', 'playbackJump'].indexOf(action) === -1) {
|
||||
console.log('dispatch: %s %o', action, args) /* log user interactions, but don't spam */
|
||||
}
|
||||
if (action === 'onOpen') {
|
||||
onOpen(args[0] /* files */)
|
||||
}
|
||||
if (action === 'addTorrent') {
|
||||
addTorrent(args[0] /* torrent */)
|
||||
}
|
||||
if (action === 'showOpenTorrentFile') {
|
||||
ipcRenderer.send('showOpenTorrentFile')
|
||||
}
|
||||
if (action === 'seed') {
|
||||
seed(args[0] /* files */)
|
||||
}
|
||||
if (action === 'play') {
|
||||
state.location.go({
|
||||
url: 'player',
|
||||
onbeforeload: function (cb) {
|
||||
// TODO: handle audio. video only for now.
|
||||
openPlayer(args[0] /* torrentSummary */, args[1] /* index */, cb)
|
||||
},
|
||||
onbeforeunload: closePlayer
|
||||
})
|
||||
}
|
||||
if (action === 'openFile') {
|
||||
openFile(args[0] /* torrentSummary */, args[1] /* index */)
|
||||
}
|
||||
if (action === 'openFolder') {
|
||||
openFolder(args[0] /* torrentSummary */)
|
||||
}
|
||||
if (action === 'toggleTorrent') {
|
||||
toggleTorrent(args[0] /* torrentSummary */)
|
||||
}
|
||||
if (action === 'deleteTorrent') {
|
||||
deleteTorrent(args[0] /* torrentSummary */)
|
||||
}
|
||||
if (action === 'toggleSelectTorrent') {
|
||||
toggleSelectTorrent(args[0] /* infoHash */)
|
||||
}
|
||||
if (action === 'openChromecast') {
|
||||
Cast.openChromecast()
|
||||
}
|
||||
if (action === 'openAirplay') {
|
||||
Cast.openAirplay()
|
||||
}
|
||||
if (action === 'stopCasting') {
|
||||
Cast.stopCasting()
|
||||
}
|
||||
if (action === 'setDimensions') {
|
||||
setDimensions(args[0] /* dimensions */)
|
||||
}
|
||||
if (action === 'back') {
|
||||
state.location.back()
|
||||
update()
|
||||
}
|
||||
if (action === 'forward') {
|
||||
state.location.forward()
|
||||
update()
|
||||
}
|
||||
if (action === 'playPause') {
|
||||
playPause()
|
||||
}
|
||||
if (action === 'play') {
|
||||
playPause(false)
|
||||
}
|
||||
if (action === 'pause') {
|
||||
playPause(true)
|
||||
}
|
||||
if (action === 'playbackJump') {
|
||||
jumpToTime(args[0] /* seconds */)
|
||||
}
|
||||
if (action === 'videoPlaying') {
|
||||
state.video.isPaused = false
|
||||
ipcRenderer.send('blockPowerSave')
|
||||
}
|
||||
if (action === 'videoPaused') {
|
||||
state.video.isPaused = true
|
||||
ipcRenderer.send('unblockPowerSave')
|
||||
}
|
||||
if (action === 'toggleFullScreen') {
|
||||
ipcRenderer.send('toggleFullScreen', args[0])
|
||||
update()
|
||||
}
|
||||
if (action === 'videoMouseMoved') {
|
||||
state.video.mouseStationarySince = new Date().getTime()
|
||||
update()
|
||||
}
|
||||
if (action === 'exitModal') {
|
||||
state.modal = null
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
// Plays or pauses the video. If isPaused is undefined, acts as a toggle
|
||||
function playPause (isPaused) {
|
||||
if (isPaused === state.video.isPaused) {
|
||||
return // Nothing to do
|
||||
}
|
||||
// Either isPaused is undefined, or it's the opposite of the current state. Toggle.
|
||||
if (Cast.isCasting()) {
|
||||
Cast.playPause()
|
||||
}
|
||||
state.video.isPaused = !state.video.isPaused
|
||||
update()
|
||||
}
|
||||
|
||||
function jumpToTime (time) {
|
||||
if (Cast.isCasting()) {
|
||||
Cast.seek(time)
|
||||
} else {
|
||||
state.video.jumpToTime = time
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
function setupIpc () {
|
||||
ipcRenderer.send('ipcReady')
|
||||
|
||||
ipcRenderer.on('log', (e, ...args) => console.log(...args))
|
||||
ipcRenderer.on('error', (e, ...args) => console.error(...args))
|
||||
|
||||
ipcRenderer.on('dispatch', (e, ...args) => dispatch(...args))
|
||||
|
||||
ipcRenderer.on('showOpenTorrentAddress', function (e) {
|
||||
state.modal = 'open-torrent-address-modal'
|
||||
update()
|
||||
})
|
||||
|
||||
ipcRenderer.on('fullscreenChanged', function (e, isFullScreen) {
|
||||
state.window.isFullScreen = isFullScreen
|
||||
update()
|
||||
})
|
||||
|
||||
ipcRenderer.on('addFakeDevice', function (e, device) {
|
||||
var player = new EventEmitter()
|
||||
player.play = (networkURL) => console.log(networkURL)
|
||||
state.devices[device] = player
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
// Load state.saved from the JSON state file
|
||||
function loadState (cb) {
|
||||
cfg.read(function (err, data) {
|
||||
if (err) console.error(err)
|
||||
console.log('loaded state from ' + cfg.filePath)
|
||||
|
||||
// populate defaults if they're not there
|
||||
state.saved = Object.assign({}, state.defaultSavedState, data)
|
||||
state.saved.torrents.forEach(function (torrentSummary) {
|
||||
if (torrentSummary.displayName) torrentSummary.name = torrentSummary.displayName
|
||||
})
|
||||
|
||||
if (cb) cb()
|
||||
})
|
||||
}
|
||||
|
||||
// Starts all torrents that aren't paused on program startup
|
||||
function resumeTorrents () {
|
||||
state.saved.torrents
|
||||
.filter((x) => x.status !== 'paused')
|
||||
.forEach((x) => startTorrentingSummary(x))
|
||||
}
|
||||
|
||||
// Write state.saved to the JSON state file
|
||||
function saveState () {
|
||||
console.log('saving state to ' + cfg.filePath)
|
||||
cfg.write(state.saved, function (err) {
|
||||
if (err) console.error(err)
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function updateClientProgress () {
|
||||
var progress = state.client.progress
|
||||
var activeTorrentsExist = state.client.torrents.some(function (torrent) {
|
||||
return torrent.progress !== 1
|
||||
})
|
||||
// Hide progress bar when client has no torrents, or progress is 100%
|
||||
if (!activeTorrentsExist || progress === 1) {
|
||||
progress = -1
|
||||
}
|
||||
state.dock.progress = progress
|
||||
}
|
||||
|
||||
function onOpen (files) {
|
||||
if (!Array.isArray(files)) files = [ files ]
|
||||
|
||||
// .torrent file = start downloading the torrent
|
||||
files.filter(isTorrent).forEach(function (torrentFile) {
|
||||
addTorrent(torrentFile)
|
||||
})
|
||||
|
||||
// everything else = seed these files
|
||||
seed(files.filter(isNotTorrent))
|
||||
}
|
||||
|
||||
function onPaste (e) {
|
||||
if (e.target.tagName.toLowerCase() === 'input') return
|
||||
|
||||
var torrentIds = clipboard.readText().split('\n')
|
||||
torrentIds.forEach(function (torrentId) {
|
||||
torrentId = torrentId.trim()
|
||||
if (torrentId.length === 0) return
|
||||
dispatch('addTorrent', torrentId)
|
||||
})
|
||||
}
|
||||
|
||||
function isTorrent (file) {
|
||||
var name = typeof file === 'string' ? file : file.name
|
||||
var isTorrentFile = path.extname(name).toLowerCase() === '.torrent'
|
||||
var isMagnet = typeof file === 'string' && /^magnet:/.test(file)
|
||||
return isTorrentFile || isMagnet
|
||||
}
|
||||
|
||||
function isNotTorrent (file) {
|
||||
return !isTorrent(file)
|
||||
}
|
||||
|
||||
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents
|
||||
// Returns undefined if we don't know that infoHash
|
||||
function getTorrentSummary (infoHash) {
|
||||
return state.saved.torrents.find((x) => x.infoHash === infoHash)
|
||||
}
|
||||
|
||||
// Get an active torrent from state.client.torrents
|
||||
// Returns undefined if we are not currently torrenting that infoHash
|
||||
function getTorrent (infoHash) {
|
||||
return state.client.torrents.find((x) => x.infoHash === infoHash)
|
||||
}
|
||||
|
||||
// Adds a torrent to the list, starts downloading/seeding. TorrentID can be a
|
||||
// magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent-
|
||||
function addTorrent (torrentId) {
|
||||
var torrent = startTorrentingID(torrentId)
|
||||
torrent.on('infoHash', function () {
|
||||
addTorrentToList(torrent)
|
||||
})
|
||||
}
|
||||
|
||||
function addTorrentToList (torrent) {
|
||||
if (getTorrentSummary(torrent.infoHash)) {
|
||||
return // Skip, torrent is already in state.saved
|
||||
}
|
||||
|
||||
// If torrentId is a remote torrent (filesystem path, http url, etc.), wait for
|
||||
// WebTorrent to finish reading it
|
||||
if (torrent.infoHash) onInfoHash()
|
||||
else torrent.on('infoHash', onInfoHash)
|
||||
|
||||
function onInfoHash () {
|
||||
state.saved.torrents.push({
|
||||
status: 'new',
|
||||
name: torrent.name,
|
||||
magnetURI: torrent.magnetURI,
|
||||
infoHash: torrent.infoHash
|
||||
})
|
||||
saveState()
|
||||
playInterfaceSound(config.SOUND_ADD)
|
||||
}
|
||||
}
|
||||
|
||||
// Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object
|
||||
function startTorrentingSummary (torrentSummary) {
|
||||
var s = torrentSummary
|
||||
if (s.torrentPath) return startTorrentingID(s.torrentPath, s.path)
|
||||
else if (s.magnetURI) return startTorrentingID(s.magnetURI, s.path)
|
||||
else return startTorrentingID(s.infoHash, s.path)
|
||||
}
|
||||
|
||||
// Starts a given TorrentID, which can be an infohash, magnet URI, etc. Returns WebTorrent object
|
||||
// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-
|
||||
function startTorrentingID (torrentID, path) {
|
||||
console.log('Starting torrent ' + torrentID)
|
||||
var torrent = state.client.add(torrentID, {
|
||||
path: path || state.saved.downloadPath // Use downloads folder
|
||||
})
|
||||
addTorrentEvents(torrent)
|
||||
return torrent
|
||||
}
|
||||
|
||||
// Stops downloading and/or seeding
|
||||
function stopTorrenting (infoHash) {
|
||||
var torrent = getTorrent(infoHash)
|
||||
if (torrent) torrent.destroy()
|
||||
}
|
||||
|
||||
// Creates a torrent for a local file and starts seeding it
|
||||
function seed (files) {
|
||||
if (files.length === 0) return
|
||||
var torrent = state.client.seed(files)
|
||||
addTorrentToList(torrent)
|
||||
addTorrentEvents(torrent)
|
||||
}
|
||||
|
||||
function addTorrentEvents (torrent) {
|
||||
torrent.on('infoHash', update)
|
||||
torrent.on('ready', torrentReady)
|
||||
torrent.on('done', torrentDone)
|
||||
|
||||
function torrentReady () {
|
||||
// Summarize torrent
|
||||
var torrentSummary = getTorrentSummary(torrent.infoHash)
|
||||
torrentSummary.status = 'downloading'
|
||||
torrentSummary.ready = true
|
||||
torrentSummary.name = torrentSummary.displayName || torrent.name
|
||||
torrentSummary.infoHash = torrent.infoHash
|
||||
torrentSummary.path = torrent.path
|
||||
|
||||
// Summarize torrent files
|
||||
torrentSummary.files = torrent.files.map(summarizeFileInTorrent)
|
||||
updateTorrentProgress()
|
||||
|
||||
// Save the .torrent file, if it hasn't been saved already
|
||||
if (!torrentSummary.torrentPath) saveTorrentFile(torrentSummary, torrent)
|
||||
|
||||
// Auto-generate a poster image, if it hasn't been generated already
|
||||
if (!torrentSummary.posterURL) generateTorrentPoster(torrent, torrentSummary)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
function torrentDone () {
|
||||
// Update the torrent summary
|
||||
var torrentSummary = getTorrentSummary(torrent.infoHash)
|
||||
torrentSummary.status = 'seeding'
|
||||
updateTorrentProgress()
|
||||
|
||||
// Notify the user that a torrent finished, but only if we actually DL'd at least part of it.
|
||||
// Don't notify if we merely finished verifying data files that were already on disk.
|
||||
if (torrent.received > 0) {
|
||||
if (!state.window.isFocused) {
|
||||
state.dock.badge += 1
|
||||
}
|
||||
showDoneNotification(torrent)
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
function updateTorrentProgress () {
|
||||
// TODO: ideally this would be tracked by WebTorrent, which could do it
|
||||
// more efficiently than looping over torrent.bitfield
|
||||
var changed = false
|
||||
state.client.torrents.forEach(function (torrent) {
|
||||
var torrentSummary = getTorrentSummary(torrent.infoHash)
|
||||
if (!torrentSummary) return
|
||||
torrent.files.forEach(function (file, index) {
|
||||
var numPieces = file._endPiece - file._startPiece + 1
|
||||
var numPiecesPresent = 0
|
||||
for (var piece = file._startPiece; piece <= file._endPiece; piece++) {
|
||||
if (torrent.bitfield.get(piece)) numPiecesPresent++
|
||||
}
|
||||
|
||||
var fileSummary = torrentSummary.files[index]
|
||||
if (fileSummary.numPiecesPresent !== numPiecesPresent || fileSummary.numPieces !== numPieces) {
|
||||
fileSummary.numPieces = numPieces
|
||||
fileSummary.numPiecesPresent = numPiecesPresent
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (changed) update()
|
||||
}
|
||||
|
||||
function generateTorrentPoster (torrent, torrentSummary) {
|
||||
torrentPoster(torrent, function (err, buf, extension) {
|
||||
if (err) return onWarning(err)
|
||||
// save it for next time
|
||||
mkdirp(config.CONFIG_POSTER_PATH, function (err) {
|
||||
if (err) return onWarning(err)
|
||||
var posterFilePath = path.join(config.CONFIG_POSTER_PATH, torrent.infoHash + extension)
|
||||
fs.writeFile(posterFilePath, buf, function (err) {
|
||||
if (err) return onWarning(err)
|
||||
// show the poster
|
||||
torrentSummary.posterURL = 'file:///' + posterFilePath
|
||||
update()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Produces a JSON saveable summary of a file in a torrent
|
||||
function summarizeFileInTorrent (file) {
|
||||
return {
|
||||
name: file.name,
|
||||
length: file.length,
|
||||
numPiecesPresent: 0,
|
||||
numPieces: null
|
||||
}
|
||||
}
|
||||
|
||||
// Every time we resolve a magnet URI, save the torrent file so that we never
|
||||
// have to download it again. Never ask the DHT the same question twice.
|
||||
function saveTorrentFile (torrentSummary, torrent) {
|
||||
checkIfTorrentFileExists(torrentSummary.infoHash, function (torrentPath, exists) {
|
||||
if (exists) {
|
||||
// We've already saved the file
|
||||
torrentSummary.torrentPath = torrentPath
|
||||
saveState()
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, save the .torrent file, under the app config folder
|
||||
fs.mkdir(config.CONFIG_TORRENT_PATH, function (_) {
|
||||
fs.writeFile(torrentPath, torrent.torrentFile, function (err) {
|
||||
if (err) return console.log('Error saving torrent file %s: %o', torrentPath, err)
|
||||
console.log('Saved torrent file %s', torrentPath)
|
||||
torrentSummary.torrentPath = torrentPath
|
||||
saveState()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Checks whether we've already resolved a given infohash to a torrent file
|
||||
// Calls back with (torrentPath, exists). Logs, does not call back on error
|
||||
function checkIfTorrentFileExists (infoHash, cb) {
|
||||
var torrentPath = path.join(config.CONFIG_TORRENT_PATH, infoHash + '.torrent')
|
||||
fs.exists(torrentPath, function (exists) {
|
||||
cb(torrentPath, exists)
|
||||
})
|
||||
}
|
||||
|
||||
function startServer (torrentSummary, index, cb) {
|
||||
if (state.server) return cb()
|
||||
|
||||
var torrent = getTorrent(torrentSummary.infoHash)
|
||||
if (!torrent) torrent = startTorrentingSummary(torrentSummary)
|
||||
if (torrent.ready) startServerFromReadyTorrent(torrent, index, cb)
|
||||
else torrent.on('ready', () => startServerFromReadyTorrent(torrent, index, cb))
|
||||
}
|
||||
|
||||
function startServerFromReadyTorrent (torrent, index, cb) {
|
||||
// automatically choose which file in the torrent to play, if necessary
|
||||
if (!index) {
|
||||
// filter out file formats that the <video> tag definitely can't play
|
||||
var files = torrent.files.filter(TorrentPlayer.isPlayable)
|
||||
if (files.length === 0) return cb(new errors.UnplayableError())
|
||||
// use largest file
|
||||
var largestFile = files.reduce(function (a, b) {
|
||||
return a.length > b.length ? a : b
|
||||
})
|
||||
index = torrent.files.indexOf(largestFile)
|
||||
}
|
||||
|
||||
// update state
|
||||
state.playing.infoHash = torrent.infoHash
|
||||
state.playing.fileIndex = index
|
||||
|
||||
var server = torrent.createServer()
|
||||
server.listen(0, function () {
|
||||
var port = server.address().port
|
||||
var urlSuffix = ':' + port + '/' + index
|
||||
state.server = {
|
||||
server: server,
|
||||
localURL: 'http://localhost' + urlSuffix,
|
||||
networkURL: 'http://' + networkAddress() + urlSuffix
|
||||
}
|
||||
cb()
|
||||
})
|
||||
}
|
||||
|
||||
function stopServer () {
|
||||
if (!state.server) return
|
||||
state.server.server.destroy()
|
||||
state.server = null
|
||||
state.playing.infoHash = null
|
||||
state.playing.fileIndex = null
|
||||
}
|
||||
|
||||
// Opens the video player
|
||||
function openPlayer (torrentSummary, index, cb) {
|
||||
var torrent = state.client.get(torrentSummary.infoHash)
|
||||
if (!torrent || !torrent.done) playInterfaceSound(config.SOUND_PLAY)
|
||||
torrentSummary.playStatus = 'requested'
|
||||
update()
|
||||
|
||||
var timeout = setTimeout(function () {
|
||||
torrentSummary.playStatus = 'timeout' /* no seeders available? */
|
||||
playInterfaceSound(config.SOUND_ERROR)
|
||||
update()
|
||||
}, 10000) /* give it a few seconds */
|
||||
|
||||
startServer(torrentSummary, index, function (err) {
|
||||
clearTimeout(timeout)
|
||||
if (err) {
|
||||
torrentSummary.playStatus = 'unplayable'
|
||||
playInterfaceSound(config.SOUND_ERROR)
|
||||
update()
|
||||
return onError(err)
|
||||
}
|
||||
|
||||
// if we timed out (user clicked play a long time ago), don't autoplay
|
||||
var timedOut = torrentSummary.playStatus === 'timeout'
|
||||
delete torrentSummary.playStatus
|
||||
if (timedOut) return update()
|
||||
|
||||
// otherwise, play the video
|
||||
state.window.title = torrentSummary.name
|
||||
update()
|
||||
cb()
|
||||
})
|
||||
}
|
||||
|
||||
function openFile (torrentSummary, index) {
|
||||
var torrent = state.client.get(torrentSummary.infoHash)
|
||||
if (!torrent) return
|
||||
|
||||
var filePath = path.join(torrent.path, torrent.files[index].path)
|
||||
ipcRenderer.send('openItem', filePath)
|
||||
}
|
||||
|
||||
function openFolder (torrentSummary) {
|
||||
var torrent = state.client.get(torrentSummary.infoHash)
|
||||
if (!torrent) return
|
||||
|
||||
var folderPath = path.join(torrent.path, torrent.name)
|
||||
// Multi-file torrents create their own folder, single file torrents just
|
||||
// drop the file directly into the Downloads folder
|
||||
fs.stat(folderPath, function (err, stats) {
|
||||
if (err || !stats.isDirectory()) {
|
||||
folderPath = torrent.path
|
||||
}
|
||||
ipcRenderer.send('openItem', folderPath)
|
||||
})
|
||||
}
|
||||
|
||||
function closePlayer (cb) {
|
||||
state.window.title = config.APP_NAME
|
||||
update()
|
||||
|
||||
if (state.window.isFullScreen) {
|
||||
dispatch('toggleFullScreen', false)
|
||||
}
|
||||
restoreBounds()
|
||||
stopServer()
|
||||
update()
|
||||
|
||||
ipcRenderer.send('unblockPowerSave')
|
||||
|
||||
cb()
|
||||
}
|
||||
|
||||
function toggleTorrent (torrentSummary) {
|
||||
if (torrentSummary.status === 'paused') {
|
||||
torrentSummary.status = 'new'
|
||||
startTorrentingSummary(torrentSummary)
|
||||
playInterfaceSound(config.SOUND_ENABLE)
|
||||
} else {
|
||||
torrentSummary.status = 'paused'
|
||||
stopTorrenting(torrentSummary.infoHash)
|
||||
playInterfaceSound(config.SOUND_DISABLE)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteTorrent (torrentSummary) {
|
||||
var infoHash = torrentSummary.infoHash
|
||||
var torrent = getTorrent(infoHash)
|
||||
if (torrent) torrent.destroy()
|
||||
|
||||
var index = state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
|
||||
if (index > -1) state.saved.torrents.splice(index, 1)
|
||||
saveState()
|
||||
state.location.clearForward() // prevent user from going forward to a deleted torrent
|
||||
playInterfaceSound(config.SOUND_DELETE)
|
||||
}
|
||||
|
||||
function toggleSelectTorrent (infoHash) {
|
||||
// toggle selection
|
||||
state.selectedInfoHash = state.selectedInfoHash === infoHash ? null : infoHash
|
||||
update()
|
||||
}
|
||||
|
||||
// Set window dimensions to match video dimensions or fill the screen
|
||||
function setDimensions (dimensions) {
|
||||
// Don't modify the window size if it's already maximized
|
||||
if (remote.getCurrentWindow().isMaximized()) {
|
||||
state.window.bounds = null
|
||||
return
|
||||
}
|
||||
|
||||
// Save the bounds of the window for later. See restoreBounds()
|
||||
state.window.bounds = {
|
||||
x: window.screenX,
|
||||
y: window.screenY,
|
||||
width: window.outerWidth,
|
||||
height: window.outerHeight
|
||||
}
|
||||
state.window.wasMaximized = remote.getCurrentWindow().isMaximized
|
||||
|
||||
// Limit window size to screen size
|
||||
var screenWidth = window.screen.width
|
||||
var screenHeight = window.screen.height
|
||||
var aspectRatio = dimensions.width / dimensions.height
|
||||
var scaleFactor = Math.min(
|
||||
Math.min(screenWidth / dimensions.width, 1),
|
||||
Math.min(screenHeight / dimensions.height, 1)
|
||||
)
|
||||
var width = Math.floor(dimensions.width * scaleFactor)
|
||||
var height = Math.floor(dimensions.height * scaleFactor)
|
||||
|
||||
// Center window on screen
|
||||
var x = Math.floor((screenWidth - width) / 2)
|
||||
var y = Math.floor((screenHeight - height) / 2)
|
||||
|
||||
ipcRenderer.send('setAspectRatio', aspectRatio)
|
||||
ipcRenderer.send('setBounds', {x, y, width, height})
|
||||
}
|
||||
|
||||
function restoreBounds () {
|
||||
ipcRenderer.send('setAspectRatio', 0)
|
||||
if (state.window.bounds) {
|
||||
ipcRenderer.send('setBounds', state.window.bounds, false)
|
||||
}
|
||||
}
|
||||
|
||||
function onError (err) {
|
||||
console.error(err.stack || err)
|
||||
playInterfaceSound(config.SOUND_ERROR)
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: err.message || err
|
||||
})
|
||||
update()
|
||||
}
|
||||
|
||||
function onWarning (err) {
|
||||
console.log('warning: %s', err.message)
|
||||
}
|
||||
|
||||
function showDoneNotification (torrent) {
|
||||
if (state.window.isFocused) return
|
||||
|
||||
var notif = new window.Notification('Download Complete', {
|
||||
body: torrent.name,
|
||||
silent: true
|
||||
})
|
||||
|
||||
notif.onclick = function () {
|
||||
window.focus()
|
||||
}
|
||||
|
||||
playInterfaceSound(config.SOUND_DONE)
|
||||
}
|
||||
|
||||
function playInterfaceSound (url) {
|
||||
var audio = new window.Audio()
|
||||
audio.volume = 0.3
|
||||
audio.src = url
|
||||
audio.play()
|
||||
}
|
||||
@@ -1,159 +1,451 @@
|
||||
var chromecasts = require('chromecasts')()
|
||||
var airplay = require('airplay-js')
|
||||
|
||||
var config = require('../../config')
|
||||
var state = require('../state')
|
||||
|
||||
// The Cast module talks to Airplay and Chromecast
|
||||
// * Modifies state when things change
|
||||
// * Starts and stops casting, provides remote video controls
|
||||
module.exports = {
|
||||
init,
|
||||
openChromecast,
|
||||
openAirplay,
|
||||
stopCasting,
|
||||
playPause,
|
||||
toggleMenu,
|
||||
selectDevice,
|
||||
stop,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
isCasting
|
||||
setVolume,
|
||||
setRate
|
||||
}
|
||||
|
||||
var airplayer = require('airplayer')()
|
||||
var chromecasts = require('chromecasts')()
|
||||
var dlnacasts = require('dlnacasts')()
|
||||
|
||||
var config = require('../../config')
|
||||
|
||||
// App state. Cast modifies state.playing and state.errors in response to events
|
||||
var state
|
||||
|
||||
// Callback to notify module users when state has changed
|
||||
var update
|
||||
|
||||
function init (callback) {
|
||||
// setInterval() for updating cast status
|
||||
var statusInterval = null
|
||||
|
||||
// Start looking for cast devices on the local network
|
||||
function init (appState, callback) {
|
||||
state = appState
|
||||
update = callback
|
||||
|
||||
// Start polling Chromecast or Airplay, whenever we're connected
|
||||
setInterval(() => pollCastStatus(state), 1000)
|
||||
state.devices.chromecast = chromecastPlayer()
|
||||
state.devices.dlna = dlnaPlayer()
|
||||
state.devices.airplay = airplayPlayer()
|
||||
|
||||
// Listen for devices: Chromecast and Airplay
|
||||
chromecasts.on('update', function (player) {
|
||||
state.devices.chromecast = player
|
||||
addChromecastEvents()
|
||||
// Listen for devices: Chromecast, DLNA and Airplay
|
||||
chromecasts.on('update', function (device) {
|
||||
// TODO: how do we tell if there are *no longer* any Chromecasts available?
|
||||
// From looking at the code, chromecasts.players only grows, never shrinks
|
||||
state.devices.chromecast.addDevice(device)
|
||||
})
|
||||
|
||||
var browser = airplay.createBrowser()
|
||||
browser.on('deviceOn', function (player) {
|
||||
state.devices.airplay = player
|
||||
addAirplayEvents()
|
||||
}).start()
|
||||
}
|
||||
|
||||
function addChromecastEvents () {
|
||||
state.devices.chromecast.on('error', function (err) {
|
||||
state.devices.chromecast.errorMessage = err.message
|
||||
update()
|
||||
dlnacasts.on('update', function (device) {
|
||||
state.devices.dlna.addDevice(device)
|
||||
})
|
||||
state.devices.chromecast.on('disconnect', function () {
|
||||
state.playing.location = 'local'
|
||||
update()
|
||||
|
||||
airplayer.on('update', function (device) {
|
||||
state.devices.airplay.addDevice(device)
|
||||
})
|
||||
}
|
||||
|
||||
function addAirplayEvents () {}
|
||||
|
||||
// Update our state from the remote TV
|
||||
function pollCastStatus (state) {
|
||||
if (state.playing.location === 'chromecast') {
|
||||
state.devices.chromecast.status(function (err, status) {
|
||||
if (err) return console.log('Error getting %s status: %o', state.playing.location, err)
|
||||
state.video.isPaused = status.playerState === 'PAUSED'
|
||||
state.video.currentTime = status.currentTime
|
||||
update()
|
||||
})
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
state.devices.airplay.status(function (status) {
|
||||
state.video.isPaused = status.rate === 0
|
||||
state.video.currentTime = status.position
|
||||
update()
|
||||
})
|
||||
// chromecast player implementation
|
||||
function chromecastPlayer () {
|
||||
var ret = {
|
||||
device: null,
|
||||
addDevice,
|
||||
getDevices,
|
||||
open,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
status,
|
||||
seek,
|
||||
volume
|
||||
}
|
||||
}
|
||||
return ret
|
||||
|
||||
function openChromecast () {
|
||||
if (state.playing.location !== 'local') {
|
||||
throw new Error('You can\'t connect to Chromecast when already connected to another device')
|
||||
function getDevices () {
|
||||
return chromecasts.players
|
||||
}
|
||||
|
||||
state.playing.location = 'chromecast-pending'
|
||||
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
|
||||
state.devices.chromecast.play(state.server.networkURL, {
|
||||
type: 'video/mp4',
|
||||
title: config.APP_NAME + ' — ' + torrentSummary.name
|
||||
}, function (err) {
|
||||
state.playing.location = err ? 'local' : 'chromecast'
|
||||
update()
|
||||
})
|
||||
update()
|
||||
}
|
||||
|
||||
function openAirplay () {
|
||||
if (state.playing.location !== 'local') {
|
||||
throw new Error('You can\'t connect to Airplay when already connected to another device')
|
||||
}
|
||||
|
||||
state.playing.location = 'airplay-pending'
|
||||
state.devices.airplay.play(state.server.networkURL, 0, function (res) {
|
||||
if (res.statusCode !== 200) {
|
||||
function addDevice (device) {
|
||||
device.on('error', function (err) {
|
||||
if (device !== ret.device) return
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Couldn\'t connect to Airplay'
|
||||
message: 'Could not connect to Chromecast. ' + err.message
|
||||
})
|
||||
} else {
|
||||
state.playing.location = 'airplay'
|
||||
}
|
||||
update()
|
||||
})
|
||||
update()
|
||||
})
|
||||
device.on('disconnect', function () {
|
||||
if (device !== ret.device) return
|
||||
state.playing.location = 'local'
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function open () {
|
||||
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
|
||||
ret.device.play(state.server.networkURL, {
|
||||
type: 'video/mp4',
|
||||
title: config.APP_NAME + ' - ' + torrentSummary.name
|
||||
}, function (err) {
|
||||
if (err) {
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to Chromecast. ' + err.message
|
||||
})
|
||||
} else {
|
||||
state.playing.location = 'chromecast'
|
||||
}
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function play (callback) {
|
||||
ret.device.play(null, null, callback)
|
||||
}
|
||||
|
||||
function pause (callback) {
|
||||
ret.device.pause(callback)
|
||||
}
|
||||
|
||||
function stop (callback) {
|
||||
ret.device.stop(callback)
|
||||
}
|
||||
|
||||
function status () {
|
||||
ret.device.status(function (err, status) {
|
||||
if (err) return console.log('error getting %s status: %o', state.playing.location, err)
|
||||
state.playing.isPaused = status.playerState === 'PAUSED'
|
||||
state.playing.currentTime = status.currentTime
|
||||
state.playing.volume = status.volume.muted ? 0 : status.volume.level
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function seek (time, callback) {
|
||||
ret.device.seek(time, callback)
|
||||
}
|
||||
|
||||
function volume (volume, callback) {
|
||||
ret.device.volume(volume, callback)
|
||||
}
|
||||
}
|
||||
|
||||
// airplay player implementation
|
||||
function airplayPlayer () {
|
||||
var ret = {
|
||||
device: null,
|
||||
addDevice,
|
||||
getDevices,
|
||||
open,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
status,
|
||||
seek,
|
||||
volume
|
||||
}
|
||||
return ret
|
||||
|
||||
function addDevice (player) {
|
||||
player.on('event', function (event) {
|
||||
switch (event.state) {
|
||||
case 'loading':
|
||||
break
|
||||
case 'playing':
|
||||
state.playing.isPaused = false
|
||||
break
|
||||
case 'paused':
|
||||
state.playing.isPaused = true
|
||||
break
|
||||
case 'stopped':
|
||||
break
|
||||
}
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function getDevices () {
|
||||
return airplayer.players
|
||||
}
|
||||
|
||||
function open () {
|
||||
ret.device.play(state.server.networkURL, function (err, res) {
|
||||
if (err) {
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to AirPlay. ' + err.message
|
||||
})
|
||||
} else {
|
||||
state.playing.location = 'airplay'
|
||||
}
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function play (callback) {
|
||||
ret.device.resume(callback)
|
||||
}
|
||||
|
||||
function pause (callback) {
|
||||
ret.device.pause(callback)
|
||||
}
|
||||
|
||||
function stop (callback) {
|
||||
ret.device.stop(callback)
|
||||
}
|
||||
|
||||
function status () {
|
||||
ret.device.playbackInfo(function (err, res, status) {
|
||||
if (err) {
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to AirPlay. ' + err.message
|
||||
})
|
||||
} else {
|
||||
state.playing.isPaused = status.rate === 0
|
||||
state.playing.currentTime = status.position
|
||||
update()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function seek (time, callback) {
|
||||
ret.device.scrub(time, callback)
|
||||
}
|
||||
|
||||
function volume (volume, callback) {
|
||||
// AirPlay doesn't support volume
|
||||
// TODO: We should just disable the volume slider
|
||||
state.playing.volume = volume
|
||||
}
|
||||
}
|
||||
|
||||
// DLNA player implementation
|
||||
function dlnaPlayer (player) {
|
||||
var ret = {
|
||||
device: null,
|
||||
addDevice,
|
||||
getDevices,
|
||||
open,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
status,
|
||||
seek,
|
||||
volume
|
||||
}
|
||||
return ret
|
||||
|
||||
function getDevices () {
|
||||
return dlnacasts.players
|
||||
}
|
||||
|
||||
function addDevice (device) {
|
||||
device.on('error', function (err) {
|
||||
if (device !== ret.device) return
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to DLNA. ' + err.message
|
||||
})
|
||||
update()
|
||||
})
|
||||
device.on('disconnect', function () {
|
||||
if (device !== ret.device) return
|
||||
state.playing.location = 'local'
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function open () {
|
||||
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
|
||||
ret.device.play(state.server.networkURL, {
|
||||
type: 'video/mp4',
|
||||
title: config.APP_NAME + ' - ' + torrentSummary.name,
|
||||
seek: state.playing.currentTime > 10 ? state.playing.currentTime : 0
|
||||
}, function (err) {
|
||||
if (err) {
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to DLNA. ' + err.message
|
||||
})
|
||||
} else {
|
||||
state.playing.location = 'dlna'
|
||||
}
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function play (callback) {
|
||||
ret.device.play(null, null, callback)
|
||||
}
|
||||
|
||||
function pause (callback) {
|
||||
ret.device.pause(callback)
|
||||
}
|
||||
|
||||
function stop (callback) {
|
||||
ret.device.stop(callback)
|
||||
}
|
||||
|
||||
function status () {
|
||||
ret.device.status(function (err, status) {
|
||||
if (err) return console.log('error getting %s status: %o', state.playing.location, err)
|
||||
state.playing.isPaused = status.playerState === 'PAUSED'
|
||||
state.playing.currentTime = status.currentTime
|
||||
state.playing.volume = status.volume.level
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function seek (time, callback) {
|
||||
ret.device.seek(time, callback)
|
||||
}
|
||||
|
||||
function volume (volume, callback) {
|
||||
ret.device.volume(volume, function (err) {
|
||||
// quick volume update
|
||||
state.playing.volume = volume
|
||||
callback(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling cast device state, whenever we're connected
|
||||
function startStatusInterval () {
|
||||
statusInterval = setInterval(function () {
|
||||
var player = getPlayer()
|
||||
if (player) player.status()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/*
|
||||
* Shows the device menu for a given cast type ('chromecast', 'airplay', etc)
|
||||
* The menu lists eg. all Chromecasts detected; the user can click one to cast.
|
||||
* If the menu was already showing for that type, hides the menu.
|
||||
*/
|
||||
function toggleMenu (location) {
|
||||
// If the menu is already showing, hide it
|
||||
if (state.devices.castMenu && state.devices.castMenu.location === location) {
|
||||
state.devices.castMenu = null
|
||||
return
|
||||
}
|
||||
|
||||
// Never cast to two devices at the same time
|
||||
if (state.playing.location !== 'local') {
|
||||
throw new Error('You can\'t connect to ' + location + ' when already connected to another device')
|
||||
}
|
||||
|
||||
// Find all cast devices of the given type
|
||||
var player = getPlayer(location)
|
||||
var devices = player ? player.getDevices() : []
|
||||
if (devices.length === 0) throw new Error('No ' + location + ' devices available')
|
||||
|
||||
// Show a menu
|
||||
state.devices.castMenu = {location, devices}
|
||||
}
|
||||
|
||||
function selectDevice (index) {
|
||||
var {location, devices} = state.devices.castMenu
|
||||
|
||||
// Start casting
|
||||
var player = getPlayer(location)
|
||||
player.device = devices[index]
|
||||
player.open()
|
||||
|
||||
// Poll the casting device's status every few seconds
|
||||
startStatusInterval()
|
||||
|
||||
// Show the Connecting... screen
|
||||
state.devices.castMenu = null
|
||||
state.playing.castName = devices[index].name
|
||||
state.playing.location = location + '-pending'
|
||||
update()
|
||||
}
|
||||
|
||||
// Stops Chromecast or Airplay, move video back to local screen
|
||||
function stopCasting () {
|
||||
if (state.playing.location === 'chromecast') {
|
||||
state.devices.chromecast.stop(stoppedCasting)
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
state.devices.airplay.stop(stoppedCasting)
|
||||
} else if (state.playing.location.endsWith('-pending')) {
|
||||
// Connecting to Chromecast took too long or errored out. Let the user cancel
|
||||
// Stops casting, move video back to local screen
|
||||
function stop () {
|
||||
var player = getPlayer()
|
||||
if (player) {
|
||||
player.stop(function () {
|
||||
player.device = null
|
||||
stoppedCasting()
|
||||
})
|
||||
clearInterval(statusInterval)
|
||||
} else {
|
||||
stoppedCasting()
|
||||
}
|
||||
}
|
||||
|
||||
function stoppedCasting () {
|
||||
state.playing.location = 'local'
|
||||
state.video.jumpToTime = state.video.currentTime
|
||||
state.playing.jumpToTime = state.playing.currentTime
|
||||
update()
|
||||
}
|
||||
|
||||
// Checks whether we are connected and already casting
|
||||
// Returns false if we not casting (state.playing.location === 'local')
|
||||
// or if we're trying to connect but haven't yet ('chromecast-pending', etc)
|
||||
function isCasting () {
|
||||
return state.playing.location === 'chromecast' || state.playing.location === 'airplay'
|
||||
function getPlayer (location) {
|
||||
if (location) {
|
||||
return state.devices[location]
|
||||
} else if (state.playing.location === 'chromecast') {
|
||||
return state.devices.chromecast
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
return state.devices.airplay
|
||||
} else if (state.playing.location === 'dlna') {
|
||||
return state.devices.dlna
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function playPause () {
|
||||
var device
|
||||
function play () {
|
||||
var player = getPlayer()
|
||||
if (player) player.play(castCallback)
|
||||
}
|
||||
|
||||
function pause () {
|
||||
var player = getPlayer()
|
||||
if (player) player.pause(castCallback)
|
||||
}
|
||||
|
||||
function setRate (rate) {
|
||||
var player
|
||||
var result = true
|
||||
if (state.playing.location === 'chromecast') {
|
||||
device = state.devices.chromecast
|
||||
if (!state.video.isPaused) device.pause(castCallback)
|
||||
else device.play(null, null, castCallback)
|
||||
// TODO find how to control playback rate on chromecast
|
||||
castCallback()
|
||||
result = false
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
device = state.devices.airplay
|
||||
if (!state.video.isPaused) device.rate(0, castCallback)
|
||||
else device.rate(1, castCallback)
|
||||
player = state.devices.airplay
|
||||
player.rate(rate, castCallback)
|
||||
} else {
|
||||
result = false
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function seek (time) {
|
||||
if (state.playing.location === 'chromecast') {
|
||||
state.devices.chromecast.seek(time, castCallback)
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
state.devices.airplay.scrub(time, castCallback)
|
||||
}
|
||||
var player = getPlayer()
|
||||
if (player) player.seek(time, castCallback)
|
||||
}
|
||||
|
||||
function setVolume (volume) {
|
||||
var player = getPlayer()
|
||||
if (player) player.volume(volume, castCallback)
|
||||
}
|
||||
|
||||
function castCallback () {
|
||||
console.log(state.playing.location + ' callback: %o', arguments)
|
||||
console.log('%s callback: %o', state.playing.location, arguments)
|
||||
}
|
||||
|
||||
39
renderer/lib/dispatcher.js
Normal file
39
renderer/lib/dispatcher.js
Normal file
@@ -0,0 +1,39 @@
|
||||
module.exports = {
|
||||
dispatch,
|
||||
dispatcher,
|
||||
setDispatch
|
||||
}
|
||||
|
||||
var dispatchers = {}
|
||||
var _dispatch = function () {}
|
||||
|
||||
function setDispatch (dispatch) {
|
||||
_dispatch = dispatch
|
||||
}
|
||||
|
||||
function dispatch (...args) {
|
||||
_dispatch(...args)
|
||||
}
|
||||
|
||||
// Most DOM event handlers are trivial functions like `() => dispatch(<args>)`.
|
||||
// For these, `dispatcher(<args>)` is preferred because it memoizes the handler
|
||||
// function. This prevents virtual-dom from updating the listener functions on
|
||||
// each update().
|
||||
function dispatcher (...args) {
|
||||
var str = JSON.stringify(args)
|
||||
var handler = dispatchers[str]
|
||||
if (!handler) {
|
||||
handler = dispatchers[str] = function (e) {
|
||||
// Do not propagate click to elements below the button
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.currentTarget.classList.contains('disabled')) {
|
||||
// Ignore clicks on disabled elements
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(...args)
|
||||
}
|
||||
}
|
||||
return handler
|
||||
}
|
||||
5
renderer/lib/hx.js
Normal file
5
renderer/lib/hx.js
Normal file
@@ -0,0 +1,5 @@
|
||||
var h = require('virtual-dom/h')
|
||||
var hyperx = require('hyperx')
|
||||
var hx = hyperx(h)
|
||||
|
||||
module.exports = hx
|
||||
@@ -4,58 +4,123 @@ function LocationHistory () {
|
||||
if (!new.target) return new LocationHistory()
|
||||
this._history = []
|
||||
this._forward = []
|
||||
this._pending = false
|
||||
}
|
||||
|
||||
LocationHistory.prototype.go = function (page) {
|
||||
console.log('go', page)
|
||||
this.clearForward()
|
||||
this._go(page)
|
||||
}
|
||||
|
||||
LocationHistory.prototype._go = function (page) {
|
||||
if (page.onbeforeload) {
|
||||
page.onbeforeload((err) => {
|
||||
if (err) return
|
||||
this._history.push(page)
|
||||
})
|
||||
} else {
|
||||
this._history.push(page)
|
||||
}
|
||||
}
|
||||
|
||||
LocationHistory.prototype.back = function () {
|
||||
if (this._history.length <= 1) return
|
||||
|
||||
var page = this._history.pop()
|
||||
|
||||
if (page.onbeforeunload) {
|
||||
page.onbeforeunload(() => {
|
||||
this._forward.push(page)
|
||||
})
|
||||
} else {
|
||||
this._forward.push(page)
|
||||
}
|
||||
}
|
||||
|
||||
LocationHistory.prototype.forward = function () {
|
||||
if (this._forward.length === 0) return
|
||||
|
||||
var page = this._forward.pop()
|
||||
this._go(page)
|
||||
}
|
||||
|
||||
LocationHistory.prototype.clearForward = function () {
|
||||
this._forward = []
|
||||
LocationHistory.prototype.url = function () {
|
||||
return this.current() && this.current().url
|
||||
}
|
||||
|
||||
LocationHistory.prototype.current = function () {
|
||||
return this._history[this._history.length - 1]
|
||||
}
|
||||
|
||||
LocationHistory.prototype.go = function (page, cb) {
|
||||
if (!cb) cb = noop
|
||||
if (this._pending) return cb(null)
|
||||
|
||||
console.log('go', page)
|
||||
|
||||
this.clearForward()
|
||||
this._go(page, cb)
|
||||
}
|
||||
|
||||
LocationHistory.prototype.back = function (cb) {
|
||||
var self = this
|
||||
if (!cb) cb = noop
|
||||
if (self._history.length <= 1 || self._pending) return cb(null)
|
||||
|
||||
var page = self._history.pop()
|
||||
self._unload(page, done)
|
||||
|
||||
function done (err) {
|
||||
if (err) return cb(err)
|
||||
self._forward.push(page)
|
||||
self._load(self.current(), cb)
|
||||
}
|
||||
}
|
||||
|
||||
LocationHistory.prototype.hasBack = function () {
|
||||
return this._history.length > 1
|
||||
}
|
||||
|
||||
LocationHistory.prototype.forward = function (cb) {
|
||||
if (!cb) cb = noop
|
||||
if (this._forward.length === 0 || this._pending) return cb(null)
|
||||
|
||||
var page = this._forward.pop()
|
||||
this._go(page, cb)
|
||||
}
|
||||
|
||||
LocationHistory.prototype.hasForward = function () {
|
||||
return this._forward.length > 0
|
||||
}
|
||||
|
||||
LocationHistory.prototype.clearForward = function (url) {
|
||||
if (url == null) {
|
||||
this._forward = []
|
||||
} else {
|
||||
console.log(this._forward)
|
||||
console.log(url)
|
||||
this._forward = this._forward.filter(function (page) {
|
||||
return page.url !== url
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
LocationHistory.prototype.backToFirst = function (cb) {
|
||||
var self = this
|
||||
if (!cb) cb = noop
|
||||
if (self._history.length <= 1) return cb(null)
|
||||
|
||||
self.back(function (err) {
|
||||
if (err) return cb(err)
|
||||
self.backToFirst(cb)
|
||||
})
|
||||
}
|
||||
|
||||
LocationHistory.prototype._go = function (page, cb) {
|
||||
var self = this
|
||||
if (!cb) cb = noop
|
||||
|
||||
self._unload(self.current(), done1)
|
||||
|
||||
function done1 (err) {
|
||||
if (err) return cb(err)
|
||||
self._load(page, done2)
|
||||
}
|
||||
|
||||
function done2 (err) {
|
||||
if (err) return cb(err)
|
||||
self._history.push(page)
|
||||
cb(null)
|
||||
}
|
||||
}
|
||||
|
||||
LocationHistory.prototype._load = function (page, cb) {
|
||||
var self = this
|
||||
self._pending = true
|
||||
|
||||
if (page && page.onbeforeload) page.onbeforeload(done)
|
||||
else done(null)
|
||||
|
||||
function done (err) {
|
||||
self._pending = false
|
||||
cb(err)
|
||||
}
|
||||
}
|
||||
|
||||
LocationHistory.prototype._unload = function (page, cb) {
|
||||
var self = this
|
||||
self._pending = true
|
||||
|
||||
if (page && page.onbeforeunload) page.onbeforeunload(done)
|
||||
else done(null)
|
||||
|
||||
function done (err) {
|
||||
self._pending = false
|
||||
cb(err)
|
||||
}
|
||||
}
|
||||
|
||||
function noop () {}
|
||||
|
||||
95
renderer/lib/migrations.js
Normal file
95
renderer/lib/migrations.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
module.exports = {
|
||||
run
|
||||
}
|
||||
|
||||
var semver = require('semver')
|
||||
var config = require('../../config')
|
||||
|
||||
// Change `state.saved` (which will be saved back to config.json on exit) as
|
||||
// needed, for example to deal with config.json format changes across versions
|
||||
function run (state) {
|
||||
// Replace "{ version: 1 }" with app version (semver)
|
||||
if (!semver.valid(state.saved.version)) {
|
||||
state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations
|
||||
}
|
||||
|
||||
var version = state.saved.version
|
||||
|
||||
if (semver.lt(version, '0.7.0')) {
|
||||
migrate_0_7_0(state.saved)
|
||||
}
|
||||
|
||||
if (semver.lt(version, '0.7.2')) {
|
||||
migrate_0_7_2(state.saved)
|
||||
}
|
||||
|
||||
// Config is now on the new version
|
||||
state.saved.version = config.APP_VERSION
|
||||
}
|
||||
|
||||
function migrate_0_7_0 (saved) {
|
||||
console.log('migrate to 0.7.0')
|
||||
|
||||
var fs = require('fs-extra')
|
||||
var path = require('path')
|
||||
|
||||
saved.torrents.forEach(function (ts) {
|
||||
var infoHash = ts.infoHash
|
||||
|
||||
// Replace torrentPath with torrentFileName
|
||||
// There are a number of cases to handle here:
|
||||
// * Originally we used absolute paths
|
||||
// * Then, relative paths for the default torrents, eg '../static/sintel.torrent'
|
||||
// * Then, paths computed at runtime for default torrents, eg 'sintel.torrent'
|
||||
// * Finally, now we're getting rid of torrentPath altogether
|
||||
var src, dst
|
||||
if (ts.torrentPath) {
|
||||
console.log('replacing torrentPath %s', ts.torrentPath)
|
||||
if (path.isAbsolute(ts.torrentPath) || ts.torrentPath.startsWith('..')) {
|
||||
src = ts.torrentPath
|
||||
} else {
|
||||
src = path.join(config.STATIC_PATH, ts.torrentPath)
|
||||
}
|
||||
dst = path.join(config.TORRENT_PATH, infoHash + '.torrent')
|
||||
// Synchronous FS calls aren't ideal, but probably OK in a migration
|
||||
// that only runs once
|
||||
if (src !== dst) fs.copySync(src, dst)
|
||||
|
||||
delete ts.torrentPath
|
||||
ts.torrentFileName = infoHash + '.torrent'
|
||||
}
|
||||
|
||||
// Replace posterURL with posterFileName
|
||||
if (ts.posterURL) {
|
||||
console.log('replacing posterURL %s', ts.posterURL)
|
||||
var extension = path.extname(ts.posterURL)
|
||||
src = path.isAbsolute(ts.posterURL)
|
||||
? ts.posterURL
|
||||
: path.join(config.STATIC_PATH, ts.posterURL)
|
||||
dst = path.join(config.POSTER_PATH, infoHash + extension)
|
||||
// Synchronous FS calls aren't ideal, but probably OK in a migration
|
||||
// that only runs once
|
||||
if (src !== dst) fs.copySync(src, dst)
|
||||
|
||||
delete ts.posterURL
|
||||
ts.posterFileName = infoHash + extension
|
||||
}
|
||||
|
||||
// Fix exception caused by incorrect file ordering.
|
||||
// https://github.com/feross/webtorrent-desktop/pull/604#issuecomment-222805214
|
||||
delete ts.defaultPlayFileIndex
|
||||
delete ts.files
|
||||
delete ts.selections
|
||||
delete ts.fileModtimes
|
||||
})
|
||||
}
|
||||
|
||||
function migrate_0_7_2 (saved) {
|
||||
if (!saved.prefs) {
|
||||
saved.prefs = {
|
||||
downloadPath: config.DEFAULT_DOWNLOAD_PATH
|
||||
}
|
||||
}
|
||||
}
|
||||
73
renderer/lib/sound.js
Normal file
73
renderer/lib/sound.js
Normal file
@@ -0,0 +1,73 @@
|
||||
module.exports = {
|
||||
preload,
|
||||
play
|
||||
}
|
||||
|
||||
var config = require('../../config')
|
||||
var path = require('path')
|
||||
|
||||
var VOLUME = 0.15
|
||||
|
||||
/* Cache of Audio elements, for instant playback */
|
||||
var cache = {}
|
||||
|
||||
var sounds = {
|
||||
ADD: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
DELETE: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'delete.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
DISABLE: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'disable.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
DONE: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'done.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
ENABLE: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'enable.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
ERROR: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'error.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
PLAY: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'play.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
STARTUP: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'startup.wav'),
|
||||
volume: VOLUME * 2
|
||||
}
|
||||
}
|
||||
|
||||
function preload () {
|
||||
for (var name in sounds) {
|
||||
if (!cache[name]) {
|
||||
var sound = sounds[name]
|
||||
var audio = cache[name] = new window.Audio()
|
||||
audio.volume = sound.volume
|
||||
audio.src = sound.url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function play (name) {
|
||||
var audio = cache[name]
|
||||
if (!audio) {
|
||||
var sound = sounds[name]
|
||||
if (!sound) {
|
||||
throw new Error('Invalid sound name')
|
||||
}
|
||||
audio = cache[name] = new window.Audio()
|
||||
audio.volume = sound.volume
|
||||
audio.src = sound.url
|
||||
}
|
||||
audio.currentTime = 0
|
||||
audio.play()
|
||||
}
|
||||
212
renderer/lib/state.js
Normal file
212
renderer/lib/state.js
Normal file
@@ -0,0 +1,212 @@
|
||||
module.exports = {
|
||||
getDefaultPlayState,
|
||||
load,
|
||||
save
|
||||
}
|
||||
|
||||
var appConfig = require('application-config')('WebTorrent')
|
||||
var path = require('path')
|
||||
|
||||
var config = require('../../config')
|
||||
var migrations = require('./migrations')
|
||||
|
||||
appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json')
|
||||
|
||||
function getDefaultState () {
|
||||
var LocationHistory = require('./location-history')
|
||||
|
||||
return {
|
||||
/*
|
||||
* Temporary state disappears once the program exits.
|
||||
* It can contain complex objects like open connections, etc.
|
||||
*/
|
||||
client: null, /* the WebTorrent client */
|
||||
server: null, /* local WebTorrent-to-HTTP server */
|
||||
prev: {}, /* used for state diffing in updateElectron() */
|
||||
location: new LocationHistory(),
|
||||
window: {
|
||||
bounds: null, /* {x, y, width, height } */
|
||||
isFocused: true,
|
||||
isFullScreen: false,
|
||||
title: config.APP_WINDOW_TITLE
|
||||
},
|
||||
selectedInfoHash: null, /* the torrent we've selected to view details. see state.torrents */
|
||||
playing: getDefaultPlayState(), /* the media (audio or video) that we're currently playing */
|
||||
devices: {}, /* playback devices like Chromecast and AppleTV */
|
||||
dock: {
|
||||
badge: 0,
|
||||
progress: 0
|
||||
},
|
||||
modal: null, /* modal popover */
|
||||
errors: [], /* user-facing errors */
|
||||
nextTorrentKey: 1, /* identify torrents for IPC between the main and webtorrent windows */
|
||||
|
||||
/*
|
||||
* Saved state is read from and written to a file every time the app runs.
|
||||
* It should be simple and minimal and must be JSON.
|
||||
* It must never contain absolute paths since we have a portable app.
|
||||
*
|
||||
* Config path:
|
||||
*
|
||||
* OS X ~/Library/Application Support/WebTorrent/config.json
|
||||
* Linux (XDG) $XDG_CONFIG_HOME/WebTorrent/config.json
|
||||
* Linux (Legacy) ~/.config/WebTorrent/config.json
|
||||
* Windows (> Vista) %LOCALAPPDATA%/WebTorrent/config.json
|
||||
* Windows (XP, 2000) %USERPROFILE%/Local Settings/Application Data/WebTorrent/config.json
|
||||
*
|
||||
* Also accessible via `require('application-config')('WebTorrent').filePath`
|
||||
*/
|
||||
saved: {},
|
||||
|
||||
/*
|
||||
* Getters, for convenience
|
||||
*/
|
||||
getPlayingTorrentSummary,
|
||||
getPlayingFileSummary
|
||||
}
|
||||
}
|
||||
|
||||
/* Whenever we stop playing video or audio, here's what we reset state.playing to */
|
||||
function getDefaultPlayState () {
|
||||
return {
|
||||
infoHash: null, /* the info hash of the torrent we're playing */
|
||||
fileIndex: null, /* the zero-based index within the torrent */
|
||||
location: 'local', /* 'local', 'chromecast', 'airplay' */
|
||||
type: null, /* 'audio' or 'video', could be 'other' if ever support eg streaming to VLC */
|
||||
currentTime: 0, /* seconds */
|
||||
duration: 1, /* seconds */
|
||||
isPaused: true,
|
||||
isStalled: false,
|
||||
lastTimeUpdate: 0, /* Unix time in ms */
|
||||
mouseStationarySince: 0, /* Unix time in ms */
|
||||
playbackRate: 1,
|
||||
subtitles: {
|
||||
tracks: [], /* subtitle tracks, each {label, language, ...} */
|
||||
selectedIndex: -1, /* current subtitle track */
|
||||
showMenu: false /* popover menu, above the video */
|
||||
},
|
||||
aspectRatio: 0 /* aspect ratio of the video */
|
||||
}
|
||||
}
|
||||
|
||||
/* If the saved state file doesn't exist yet, here's what we use instead */
|
||||
function setupSavedState (cb) {
|
||||
var fs = require('fs-extra')
|
||||
var parseTorrent = require('parse-torrent')
|
||||
var parallel = require('run-parallel')
|
||||
|
||||
var saved = {
|
||||
prefs: {
|
||||
downloadPath: config.DEFAULT_DOWNLOAD_PATH
|
||||
},
|
||||
torrents: config.DEFAULT_TORRENTS.map(createTorrentObject),
|
||||
version: config.APP_VERSION /* make sure we can upgrade gracefully later */
|
||||
}
|
||||
|
||||
var tasks = []
|
||||
|
||||
config.DEFAULT_TORRENTS.map(function (t, i) {
|
||||
var infoHash = saved.torrents[i].infoHash
|
||||
tasks.push(function (cb) {
|
||||
fs.copy(
|
||||
path.join(config.STATIC_PATH, t.posterFileName),
|
||||
path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName)),
|
||||
cb
|
||||
)
|
||||
})
|
||||
tasks.push(function (cb) {
|
||||
fs.copy(
|
||||
path.join(config.STATIC_PATH, t.torrentFileName),
|
||||
path.join(config.TORRENT_PATH, infoHash + '.torrent'),
|
||||
cb
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
parallel(tasks, function (err) {
|
||||
if (err) return cb(err)
|
||||
cb(null, saved)
|
||||
})
|
||||
|
||||
function createTorrentObject (t) {
|
||||
var torrent = fs.readFileSync(path.join(config.STATIC_PATH, t.torrentFileName))
|
||||
var parsedTorrent = parseTorrent(torrent)
|
||||
|
||||
return {
|
||||
status: 'paused',
|
||||
infoHash: parsedTorrent.infoHash,
|
||||
name: t.name,
|
||||
displayName: t.name,
|
||||
posterFileName: parsedTorrent.infoHash + path.extname(t.posterFileName),
|
||||
torrentFileName: parsedTorrent.infoHash + '.torrent',
|
||||
magnetURI: parseTorrent.toMagnetURI(parsedTorrent),
|
||||
files: parsedTorrent.files,
|
||||
selections: parsedTorrent.files.map((x) => true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayingTorrentSummary () {
|
||||
var infoHash = this.playing.infoHash
|
||||
return this.saved.torrents.find((x) => x.infoHash === infoHash)
|
||||
}
|
||||
|
||||
function getPlayingFileSummary () {
|
||||
var torrentSummary = this.getPlayingTorrentSummary()
|
||||
if (!torrentSummary) return null
|
||||
return torrentSummary.files[this.playing.fileIndex]
|
||||
}
|
||||
|
||||
function load (cb) {
|
||||
var state = getDefaultState()
|
||||
|
||||
appConfig.read(function (err, saved) {
|
||||
if (err || !saved.version) {
|
||||
console.log('Missing config file: Creating new one')
|
||||
setupSavedState(onSaved)
|
||||
} else {
|
||||
onSaved(null, saved)
|
||||
}
|
||||
})
|
||||
|
||||
function onSaved (err, saved) {
|
||||
if (err) return cb(err)
|
||||
state.saved = saved
|
||||
migrations.run(state)
|
||||
cb(null, state)
|
||||
}
|
||||
}
|
||||
|
||||
// Write state.saved to the JSON state file
|
||||
function save (state, cb) {
|
||||
console.log('Saving state to ' + appConfig.filePath)
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
// Clean up, so that we're not saving any pending state
|
||||
var copy = Object.assign({}, state.saved)
|
||||
// Remove torrents pending addition to the list, where we haven't finished
|
||||
// reading the torrent file or file(s) to seed & don't have an infohash
|
||||
copy.torrents = copy.torrents
|
||||
.filter((x) => x.infoHash)
|
||||
.map(function (x) {
|
||||
var torrent = {}
|
||||
for (var key in x) {
|
||||
if (key === 'progress' || key === 'torrentKey') {
|
||||
continue // Don't save progress info or key for the webtorrent process
|
||||
}
|
||||
if (key === 'playStatus') {
|
||||
continue // Don't save whether a torrent is playing / pending
|
||||
}
|
||||
torrent[key] = x[key]
|
||||
}
|
||||
return torrent
|
||||
})
|
||||
|
||||
appConfig.write(copy, function (err) {
|
||||
if (err) console.error(err)
|
||||
|
||||
// TODO: this doesn't belong here
|
||||
electron.ipcRenderer.send('savedState')
|
||||
})
|
||||
}
|
||||
140
renderer/lib/telemetry.js
Normal file
140
renderer/lib/telemetry.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// Collects anonymous usage stats and uncaught errors
|
||||
// Reports back so that we can improve WebTorrent Desktop
|
||||
module.exports = {
|
||||
init,
|
||||
logUncaughtError,
|
||||
logPlayAttempt
|
||||
}
|
||||
|
||||
const crypto = require('crypto')
|
||||
const electron = require('electron')
|
||||
const https = require('https')
|
||||
const os = require('os')
|
||||
const url = require('url')
|
||||
|
||||
const config = require('../../config')
|
||||
|
||||
var telemetry
|
||||
|
||||
function init (state) {
|
||||
telemetry = state.saved.telemetry
|
||||
if (!telemetry) {
|
||||
telemetry = state.saved.telemetry = createSummary()
|
||||
reset()
|
||||
}
|
||||
|
||||
var now = new Date()
|
||||
telemetry.timestamp = now.toISOString()
|
||||
telemetry.localTime = now.toTimeString()
|
||||
telemetry.screens = getScreenInfo()
|
||||
telemetry.system = getSystemInfo()
|
||||
telemetry.approxNumTorrents = getApproxNumTorrents(state)
|
||||
|
||||
postToServer(telemetry)
|
||||
}
|
||||
|
||||
function reset () {
|
||||
telemetry.uncaughtErrors = []
|
||||
telemetry.playAttempts = {
|
||||
total: 0,
|
||||
success: 0,
|
||||
timeout: 0,
|
||||
error: 0,
|
||||
abandoned: 0
|
||||
}
|
||||
}
|
||||
|
||||
function postToServer () {
|
||||
// Serialize the telemetry summary
|
||||
var payload = new Buffer(JSON.stringify(telemetry), 'utf8')
|
||||
|
||||
// POST to our server
|
||||
var options = url.parse(config.TELEMETRY_URL)
|
||||
options.method = 'POST'
|
||||
options.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': payload.length
|
||||
}
|
||||
|
||||
var req = https.request(options, function (res) {
|
||||
if (res.statusCode === 200) {
|
||||
console.log('Successfully posted telemetry summary')
|
||||
reset()
|
||||
} else {
|
||||
console.error('Couldn\'t post telemetry summary, got HTTP ' + res.statusCode)
|
||||
}
|
||||
})
|
||||
req.on('error', function (e) {
|
||||
console.error('Couldn\'t post telemetry summary', e)
|
||||
})
|
||||
req.write(payload)
|
||||
req.end()
|
||||
}
|
||||
|
||||
// Creates a new telemetry summary. Gives the user a unique ID,
|
||||
// collects screen resolution, etc
|
||||
function createSummary () {
|
||||
// Make a 256-bit random unique ID
|
||||
var userID = crypto.randomBytes(32).toString('hex')
|
||||
return { userID }
|
||||
}
|
||||
|
||||
// Track screen resolution
|
||||
function getScreenInfo () {
|
||||
return electron.screen.getAllDisplays().map((screen) => ({
|
||||
width: screen.size.width,
|
||||
height: screen.size.height,
|
||||
scaleFactor: screen.scaleFactor
|
||||
}))
|
||||
}
|
||||
|
||||
// Track basic system info like OS version and amount of RAM
|
||||
function getSystemInfo () {
|
||||
return {
|
||||
osPlatform: process.platform,
|
||||
osRelease: os.type() + ' ' + os.release(),
|
||||
architecture: os.arch(),
|
||||
totalMemoryMB: os.totalmem() / (1 << 20),
|
||||
numCores: os.cpus().length
|
||||
}
|
||||
}
|
||||
|
||||
// Get the number of torrents, rounded to the nearest power of two
|
||||
function getApproxNumTorrents (state) {
|
||||
var exactNum = state.saved.torrents.length
|
||||
if (exactNum === 0) return 0
|
||||
// Otherwise, return 1, 2, 4, 8, etc by rounding in log space
|
||||
var log2 = Math.log(exactNum) / Math.log(2)
|
||||
return 1 << Math.round(log2)
|
||||
}
|
||||
|
||||
// An uncaught error happened in the main process or in one of the windows
|
||||
function logUncaughtError (procName, err) {
|
||||
var message, stack
|
||||
if (typeof err === 'string') {
|
||||
message = err
|
||||
stack = ''
|
||||
} else {
|
||||
message = err.message
|
||||
stack = err.stack
|
||||
}
|
||||
|
||||
// We need to POST the telemetry object, make sure it stays < 100kb
|
||||
if (telemetry.uncaughtErrors.length > 20) return
|
||||
if (message.length > 1000) message = message.substring(0, 1000)
|
||||
if (stack.length > 1000) stack = stack.substring(0, 1000)
|
||||
|
||||
telemetry.uncaughtErrors.push({process: procName, message, stack})
|
||||
}
|
||||
|
||||
// The user pressed play. It either worked, timed out, or showed the
|
||||
// "Play in VLC" codec error
|
||||
function logPlayAttempt (result) {
|
||||
if (!['success', 'timeout', 'error', 'abandoned'].includes(result)) {
|
||||
return console.error('Unknown play attempt result', result)
|
||||
}
|
||||
|
||||
var attempts = telemetry.playAttempts
|
||||
attempts.total = (attempts.total || 0) + 1
|
||||
attempts[result] = (attempts[result] || 0) + 1
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
module.exports = {
|
||||
isPlayable: isPlayable
|
||||
isPlayable,
|
||||
isVideo,
|
||||
isAudio,
|
||||
isPlayableTorrent
|
||||
}
|
||||
|
||||
var path = require('path')
|
||||
@@ -8,6 +11,35 @@ var path = require('path')
|
||||
* Determines whether a file in a torrent is audio/video we can play
|
||||
*/
|
||||
function isPlayable (file) {
|
||||
var extname = path.extname(file.name)
|
||||
return ['.mp4', '.m4v', '.webm', '.mov', '.mkv'].indexOf(extname) !== -1
|
||||
return isVideo(file) || isAudio(file)
|
||||
}
|
||||
|
||||
function isVideo (file) {
|
||||
var ext = path.extname(file.name).toLowerCase()
|
||||
return [
|
||||
'.avi',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp4',
|
||||
'.mpg',
|
||||
'.ogv',
|
||||
'.webm',
|
||||
'.wmv'
|
||||
].includes(ext)
|
||||
}
|
||||
|
||||
function isAudio (file) {
|
||||
var ext = path.extname(file.name).toLowerCase()
|
||||
return [
|
||||
'.aac',
|
||||
'.ac3',
|
||||
'.mp3',
|
||||
'.ogg',
|
||||
'.wav'
|
||||
].includes(ext)
|
||||
}
|
||||
|
||||
function isPlayableTorrent (torrentSummary) {
|
||||
return torrentSummary.files && torrentSummary.files.some(isPlayable)
|
||||
}
|
||||
|
||||
@@ -4,13 +4,19 @@ var captureVideoFrame = require('./capture-video-frame')
|
||||
var path = require('path')
|
||||
|
||||
function torrentPoster (torrent, cb) {
|
||||
// First, try to use the largest video file
|
||||
// First, try to use a poster image if available
|
||||
var posterFile = torrent.files.filter(function (file) {
|
||||
return /^poster\.(jpg|png|gif)$/.test(file.name)
|
||||
})[0]
|
||||
if (posterFile) return torrentPosterFromImage(posterFile, torrent, cb)
|
||||
|
||||
// Second, try to use the largest video file
|
||||
// Filter out file formats that the <video> tag definitely can't play
|
||||
var videoFile = getLargestFileByExtension(torrent, ['.mp4', '.m4v', '.webm', '.mov', '.mkv'])
|
||||
if (videoFile) return torrentPosterFromVideo(videoFile, torrent, cb)
|
||||
|
||||
// Second, try to use the largest image file
|
||||
var imgFile = getLargestFileByExtension(torrent, ['.gif', '.jpg', '.png'])
|
||||
// Third, try to use the largest image file
|
||||
var imgFile = getLargestFileByExtension(torrent, ['.gif', '.jpg', '.jpeg', '.png'])
|
||||
if (imgFile) return torrentPosterFromImage(imgFile, torrent, cb)
|
||||
|
||||
// TODO: generate a waveform from the largest sound file
|
||||
@@ -20,7 +26,7 @@ function torrentPoster (torrent, cb) {
|
||||
|
||||
function getLargestFileByExtension (torrent, extensions) {
|
||||
var files = torrent.files.filter(function (file) {
|
||||
var extname = path.extname(file.name)
|
||||
var extname = path.extname(file.name).toLowerCase()
|
||||
return extensions.indexOf(extname) !== -1
|
||||
})
|
||||
if (files.length === 0) return undefined
|
||||
@@ -64,6 +70,8 @@ function torrentPosterFromVideo (file, torrent, cb) {
|
||||
|
||||
server.destroy()
|
||||
|
||||
if (buf.length === 0) return cb(new Error('Generated poster contains no data'))
|
||||
|
||||
cb(null, buf, '.jpg')
|
||||
}
|
||||
}
|
||||
|
||||
24
renderer/lib/torrent-summary.js
Normal file
24
renderer/lib/torrent-summary.js
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
getPosterPath,
|
||||
getTorrentPath
|
||||
}
|
||||
|
||||
var path = require('path')
|
||||
var config = require('../../config')
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns an absolute path to the torrent file, or null if unavailable
|
||||
function getTorrentPath (torrentSummary) {
|
||||
if (!torrentSummary || !torrentSummary.torrentFileName) return null
|
||||
return path.join(config.TORRENT_PATH, torrentSummary.torrentFileName)
|
||||
}
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns an absolute path to the poster image, or null if unavailable
|
||||
function getPosterPath (torrentSummary) {
|
||||
if (!torrentSummary || !torrentSummary.posterFileName) return null
|
||||
var posterPath = path.join(config.POSTER_PATH, torrentSummary.posterFileName)
|
||||
// Work around a Chrome bug (reproduced in vanilla Chrome, not just Electron):
|
||||
// Backslashes in URLS in CSS cause bizarre string encoding issues
|
||||
return posterPath.replace(/\\/g, '/')
|
||||
}
|
||||
1239
renderer/main.css
Normal file
1239
renderer/main.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,9 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="index.css" charset="utf-8">
|
||||
<link rel="stylesheet" href="main.css" charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
<div class="loading"><i class="icon">sync</i></div>
|
||||
<script async src="index.js"></script>
|
||||
<script async src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1371
renderer/main.js
Normal file
1371
renderer/main.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,112 +0,0 @@
|
||||
var os = require('os')
|
||||
var path = require('path')
|
||||
|
||||
var config = require('../config')
|
||||
var LocationHistory = require('./lib/location-history')
|
||||
|
||||
module.exports = {
|
||||
/*
|
||||
* Temporary state disappears once the program exits.
|
||||
* It can contain complex objects like open connections, etc.
|
||||
*/
|
||||
client: null, /* the WebTorrent client */
|
||||
server: null, /* local WebTorrent-to-HTTP server */
|
||||
prev: {}, /* used for state diffing in updateElectron() */
|
||||
location: new LocationHistory(),
|
||||
window: {
|
||||
bounds: null, /* {x, y, width, height } */
|
||||
isFocused: true,
|
||||
isFullScreen: false,
|
||||
title: config.APP_NAME /* current window title */
|
||||
},
|
||||
selectedInfoHash: null, /* the torrent we've selected to view details. see state.torrents */
|
||||
playing: { /* the torrent and file we're currently streaming */
|
||||
infoHash: null, /* the info hash of the torrent we're playing */
|
||||
fileIndex: null, /* the zero-based index within the torrent */
|
||||
location: 'local' /* 'local', 'chromecast', 'airplay' */
|
||||
},
|
||||
devices: { /* playback devices like Chromecast and AppleTV */
|
||||
airplay: null, /* airplay client. finds and manages AppleTVs */
|
||||
chromecast: null /* chromecast client. finds and manages Chromecasts */
|
||||
},
|
||||
video: { /* state of the video player screen */
|
||||
currentTime: 0, /* seconds */
|
||||
duration: 1, /* seconds */
|
||||
isPaused: true,
|
||||
mouseStationarySince: 0 /* Unix time in ms */
|
||||
},
|
||||
dock: {
|
||||
badge: 0,
|
||||
progress: 0
|
||||
},
|
||||
errors: [], /* user-facing errors */
|
||||
|
||||
/*
|
||||
* Saved state is read from and written to a file every time the app runs.
|
||||
* It should be simple and minimal and must be JSON.
|
||||
*
|
||||
* Config path:
|
||||
*
|
||||
* OS X ~/Library/Application Support/WebTorrent/config.json
|
||||
* Linux (XDG) $XDG_CONFIG_HOME/WebTorrent/config.json
|
||||
* Linux (Legacy) ~/.config/WebTorrent/config.json
|
||||
* Windows (> Vista) %LOCALAPPDATA%/WebTorrent/config.json
|
||||
* Windows (XP, 2000) %USERPROFILE%/Local Settings/Application Data/WebTorrent/config.json
|
||||
*
|
||||
* Also accessible via `require('application-config')('WebTorrent').filePath`
|
||||
*/
|
||||
saved: {},
|
||||
|
||||
/* If the saved state file doesn't exist yet, here's what we use instead */
|
||||
defaultSavedState: {
|
||||
version: 1, /* make sure we can upgrade gracefully later */
|
||||
torrents: [
|
||||
{
|
||||
status: 'paused',
|
||||
infoHash: '88594aaacbde40ef3e2510c47374ec0aa396c08e',
|
||||
displayName: 'Big Buck Bunny',
|
||||
posterURL: path.join(config.ROOT_PATH, 'static', 'bigBuckBunny.jpg'),
|
||||
torrentPath: path.join(config.ROOT_PATH, 'static', 'bigBuckBunny.torrent'),
|
||||
files: [
|
||||
{
|
||||
'name': 'bbb_sunflower_1080p_30fps_normal.mp4',
|
||||
'length': 276134947,
|
||||
'numPiecesPresent': 0,
|
||||
'numPieces': 527
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
status: 'paused',
|
||||
infoHash: '6a9759bffd5c0af65319979fb7832189f4f3c35d',
|
||||
displayName: 'Sintel',
|
||||
posterURL: path.join(config.ROOT_PATH, 'static', 'sintel.jpg'),
|
||||
torrentPath: path.join(config.ROOT_PATH, 'static', 'sintel.torrent'),
|
||||
files: [
|
||||
{
|
||||
'name': 'sintel.mp4',
|
||||
'length': 129241752,
|
||||
'numPiecesPresent': 0,
|
||||
'numPieces': 987
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
status: 'paused',
|
||||
infoHash: '02767050e0be2fd4db9a2ad6c12416ac806ed6ed',
|
||||
displayName: 'Tears of Steel',
|
||||
posterURL: path.join(config.ROOT_PATH, 'static', 'tearsOfSteel.jpg'),
|
||||
torrentPath: path.join(config.ROOT_PATH, 'static', 'tearsOfSteel.torrent'),
|
||||
files: [
|
||||
{
|
||||
'name': 'tears_of_steel_1080p.webm',
|
||||
'length': 571346576,
|
||||
'numPiecesPresent': 0,
|
||||
'numPieces': 2180
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
downloadPath: path.join(os.homedir(), 'Downloads')
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,82 @@
|
||||
module.exports = App
|
||||
|
||||
var h = require('virtual-dom/h')
|
||||
var hyperx = require('hyperx')
|
||||
var hx = hyperx(h)
|
||||
|
||||
var hx = require('../lib/hx')
|
||||
var Header = require('./header')
|
||||
var Player = require('./player')
|
||||
var TorrentList = require('./torrent-list')
|
||||
var Modals = {
|
||||
'open-torrent-address-modal': require('./open-torrent-address-modal')
|
||||
|
||||
var Views = {
|
||||
'home': require('./home'),
|
||||
'player': require('./player'),
|
||||
'create-torrent': require('./create-torrent'),
|
||||
'preferences': require('./preferences')
|
||||
}
|
||||
|
||||
function App (state, dispatch) {
|
||||
var Modals = {
|
||||
'open-torrent-address-modal': require('./open-torrent-address-modal'),
|
||||
'update-available-modal': require('./update-available-modal'),
|
||||
'unsupported-media-modal': require('./unsupported-media-modal')
|
||||
}
|
||||
|
||||
function App (state) {
|
||||
// Hide player controls while playing video, if the mouse stays still for a while
|
||||
// Never hide the controls when:
|
||||
// * The mouse is over the controls or we're scrubbing (see CSS)
|
||||
// * The video is paused
|
||||
// * The video is playing remotely on Chromecast or Airplay
|
||||
var hideControls = state.location.current().url === 'player' &&
|
||||
state.video.mouseStationarySince !== 0 &&
|
||||
new Date().getTime() - state.video.mouseStationarySince > 2000 &&
|
||||
!state.video.isPaused &&
|
||||
state.playing.location === 'local'
|
||||
|
||||
// Hide the header on Windows/Linux when in the player
|
||||
// On OSX, the header appears as part of the title bar
|
||||
var hideHeader = process.platform !== 'darwin' && state.location.current().url === 'player'
|
||||
var hideControls = state.location.url() === 'player' &&
|
||||
state.playing.mouseStationarySince !== 0 &&
|
||||
new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
|
||||
!state.playing.isPaused &&
|
||||
state.playing.location === 'local' &&
|
||||
state.playing.playbackRate === 1
|
||||
|
||||
var cls = [
|
||||
'view-' + state.location.current().url, /* e.g. view-home, view-player */
|
||||
'view-' + state.location.url(), /* e.g. view-home, view-player */
|
||||
'is-' + process.platform /* e.g. is-darwin, is-win32, is-linux */
|
||||
]
|
||||
if (state.window.isFullScreen) cls.push('is-fullscreen')
|
||||
if (state.window.isFocused) cls.push('is-focused')
|
||||
if (hideControls) cls.push('hide-video-controls')
|
||||
if (hideHeader) cls.push('hide-header')
|
||||
|
||||
return hx`
|
||||
<div class='app ${cls.join(' ')}'>
|
||||
${Header(state, dispatch)}
|
||||
${getErrorPopover()}
|
||||
<div class='content'>${getView()}</div>
|
||||
${getModal()}
|
||||
${Header(state)}
|
||||
${getErrorPopover(state)}
|
||||
<div class='content'>${getView(state)}</div>
|
||||
${getModal(state)}
|
||||
</div>
|
||||
`
|
||||
|
||||
function getErrorPopover () {
|
||||
var now = new Date().getTime()
|
||||
var recentErrors = state.errors.filter((x) => now - x.time < 5000)
|
||||
|
||||
var errorElems = recentErrors.map(function (error) {
|
||||
return hx`<div class='error'>${error.message}</div>`
|
||||
})
|
||||
return hx`
|
||||
<div class='error-popover ${recentErrors.length > 0 ? 'visible' : 'hidden'}'>
|
||||
<div class='title'>Error</div>
|
||||
${errorElems}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function getModal () {
|
||||
if (state.modal) {
|
||||
var contents = Modals[state.modal](state, dispatch)
|
||||
return hx`
|
||||
<div class='modal'>
|
||||
<div class='modal-background'></div>
|
||||
<div class='modal-content add-file-modal'>
|
||||
${contents}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
function getView () {
|
||||
if (state.location.current().url === 'home') {
|
||||
return TorrentList(state, dispatch)
|
||||
} else if (state.location.current().url === 'player') {
|
||||
return Player(state, dispatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorPopover (state) {
|
||||
var now = new Date().getTime()
|
||||
var recentErrors = state.errors.filter((x) => now - x.time < 5000)
|
||||
var hasErrors = recentErrors.length > 0
|
||||
|
||||
var errorElems = recentErrors.map(function (error) {
|
||||
return hx`<div class='error'>${error.message}</div>`
|
||||
})
|
||||
return hx`
|
||||
<div class='error-popover ${hasErrors ? 'visible' : 'hidden'}'>
|
||||
<div class='title'>Error</div>
|
||||
${errorElems}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function getModal (state) {
|
||||
if (!state.modal) return
|
||||
var contents = Modals[state.modal.id](state)
|
||||
return hx`
|
||||
<div class='modal'>
|
||||
<div class='modal-background'></div>
|
||||
<div class='modal-content'>
|
||||
${contents}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function getView (state) {
|
||||
var url = state.location.url()
|
||||
return Views[url](state)
|
||||
}
|
||||
|
||||
159
renderer/views/create-torrent.js
Normal file
159
renderer/views/create-torrent.js
Normal file
@@ -0,0 +1,159 @@
|
||||
module.exports = CreateTorrentPage
|
||||
|
||||
var createTorrent = require('create-torrent')
|
||||
var path = require('path')
|
||||
var prettyBytes = require('prettier-bytes')
|
||||
|
||||
var {dispatch, dispatcher} = require('../lib/dispatcher')
|
||||
var hx = require('../lib/hx')
|
||||
|
||||
function CreateTorrentPage (state) {
|
||||
var info = state.location.current()
|
||||
|
||||
// Preprocess: exclude .DS_Store and other dotfiles
|
||||
var files = info.files
|
||||
.filter((f) => !f.name.startsWith('.'))
|
||||
.map((f) => ({name: f.name, path: f.path, size: f.size}))
|
||||
if (files.length === 0) return CreateTorrentErrorPage()
|
||||
|
||||
// First, extract the base folder that the files are all in
|
||||
var pathPrefix = info.folderPath
|
||||
if (!pathPrefix) {
|
||||
pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix)
|
||||
if (!pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) {
|
||||
pathPrefix = path.dirname(pathPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check: show the number of files and total size
|
||||
var numFiles = files.length
|
||||
var totalBytes = files
|
||||
.map((f) => f.size)
|
||||
.reduce((a, b) => a + b, 0)
|
||||
var torrentInfo = `${numFiles} files, ${prettyBytes(totalBytes)}`
|
||||
|
||||
// Then, use the name of the base folder (or sole file, for a single file torrent)
|
||||
// as the default name. Show all files relative to the base folder.
|
||||
var defaultName, basePath
|
||||
if (files.length === 1) {
|
||||
// Single file torrent: /a/b/foo.jpg -> torrent name "foo.jpg", path "/a/b"
|
||||
defaultName = files[0].name
|
||||
basePath = pathPrefix
|
||||
} else {
|
||||
// Multi file torrent: /a/b/{foo, bar}.jpg -> torrent name "b", path "/a"
|
||||
defaultName = path.basename(pathPrefix)
|
||||
basePath = path.dirname(pathPrefix)
|
||||
}
|
||||
var maxFileElems = 100
|
||||
var fileElems = files.slice(0, maxFileElems).map(function (file) {
|
||||
var relativePath = files.length === 0 ? file.name : path.relative(pathPrefix, file.path)
|
||||
return hx`<div>${relativePath}</div>`
|
||||
})
|
||||
if (files.length > maxFileElems) {
|
||||
fileElems.push(hx`<div>+ ${maxFileElems - files.length} more</div>`)
|
||||
}
|
||||
var trackers = createTorrent.announceList.join('\n')
|
||||
var collapsedClass = info.showAdvanced ? 'expanded' : 'collapsed'
|
||||
|
||||
return hx`
|
||||
<div class='create-torrent'>
|
||||
<h2>Create torrent ${defaultName}</h2>
|
||||
<p class="torrent-info">
|
||||
${torrentInfo}
|
||||
</p>
|
||||
<p class='torrent-attribute'>
|
||||
<label>Path:</label>
|
||||
<div class='torrent-attribute'>${pathPrefix}</div>
|
||||
</p>
|
||||
<div class='expand-collapse ${collapsedClass}' onclick=${handleToggleShowAdvanced}>
|
||||
${info.showAdvanced ? 'Basic' : 'Advanced'}
|
||||
</div>
|
||||
<div class="create-torrent-advanced ${collapsedClass}">
|
||||
<p class='torrent-attribute'>
|
||||
<label>Comment:</label>
|
||||
<textarea class='torrent-attribute torrent-comment'></textarea>
|
||||
</p>
|
||||
<p class='torrent-attribute'>
|
||||
<label>Trackers:</label>
|
||||
<textarea class='torrent-attribute torrent-trackers'>${trackers}</textarea>
|
||||
</p>
|
||||
<p class='torrent-attribute'>
|
||||
<label>Private:</label>
|
||||
<input type='checkbox' class='torrent-is-private' value='torrent-is-private'>
|
||||
</p>
|
||||
<p class='torrent-attribute'>
|
||||
<label>Files:</label>
|
||||
<div>${fileElems}</div>
|
||||
</p>
|
||||
</div>
|
||||
<p class="float-right">
|
||||
<button class='button-flat light' onclick=${handleCancel}>Cancel</button>
|
||||
<button class='button-raised' onclick=${handleOK}>Create Torrent</button>
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
function handleOK () {
|
||||
var announceList = document.querySelector('.torrent-trackers').value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
var isPrivate = document.querySelector('.torrent-is-private').checked
|
||||
var comment = document.querySelector('.torrent-comment').value.trim()
|
||||
var options = {
|
||||
// We can't let the user choose their own name if we want WebTorrent
|
||||
// to use the files in place rather than creating a new folder.
|
||||
// If we ever want to add support for that:
|
||||
// name: document.querySelector('.torrent-name').value
|
||||
name: defaultName,
|
||||
path: basePath,
|
||||
files: files,
|
||||
announce: announceList,
|
||||
private: isPrivate,
|
||||
comment: comment
|
||||
}
|
||||
dispatch('createTorrent', options)
|
||||
}
|
||||
|
||||
function handleCancel () {
|
||||
dispatch('back')
|
||||
}
|
||||
|
||||
function handleToggleShowAdvanced () {
|
||||
// TODO: what's the clean way to handle this?
|
||||
// Should every button on every screen have its own dispatch()?
|
||||
info.showAdvanced = !info.showAdvanced
|
||||
dispatch('update')
|
||||
}
|
||||
}
|
||||
|
||||
function CreateTorrentErrorPage () {
|
||||
return hx`
|
||||
<div class='create-torrent'>
|
||||
<h2>Create torrent</h2>
|
||||
<p class="torrent-info">
|
||||
<p>
|
||||
Sorry, you must select at least one file that is not a hidden file.
|
||||
</p>
|
||||
<p>
|
||||
Hidden files, starting with a . character, are not included.
|
||||
</p>
|
||||
</p>
|
||||
<p class="float-right">
|
||||
<button class='button-flat light' onclick=${dispatcher('back')}>
|
||||
Cancel
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Finds the longest common prefix
|
||||
function findCommonPrefix (a, b) {
|
||||
for (var i = 0; i < a.length && i < b.length; i++) {
|
||||
if (a.charCodeAt(i) !== b.charCodeAt(i)) break
|
||||
}
|
||||
if (i === a.length) return a
|
||||
if (i === b.length) return b
|
||||
return a.substring(0, i)
|
||||
}
|
||||
@@ -1,28 +1,27 @@
|
||||
module.exports = Header
|
||||
|
||||
var h = require('virtual-dom/h')
|
||||
var hyperx = require('hyperx')
|
||||
var hx = hyperx(h)
|
||||
var {dispatcher} = require('../lib/dispatcher')
|
||||
var hx = require('../lib/hx')
|
||||
|
||||
function Header (state, dispatch) {
|
||||
function Header (state) {
|
||||
return hx`
|
||||
<div class='header'>
|
||||
${getTitle()}
|
||||
<div class='nav left'>
|
||||
<div class='nav left float-left'>
|
||||
<i.icon.back
|
||||
class=${state.location.hasBack() ? '' : 'disabled'}
|
||||
title='Back'
|
||||
onclick=${() => dispatch('back')}>
|
||||
onclick=${dispatcher('back')}>
|
||||
chevron_left
|
||||
</i>
|
||||
<i.icon.forward
|
||||
class=${state.location.hasForward() ? '' : 'disabled'}
|
||||
title='Forward'
|
||||
onclick=${() => dispatch('forward')}>
|
||||
onclick=${dispatcher('forward')}>
|
||||
chevron_right
|
||||
</i>
|
||||
</div>
|
||||
<div class='nav right'>
|
||||
<div class='nav right float-right'>
|
||||
${getAddButton()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,12 +34,12 @@ function Header (state, dispatch) {
|
||||
}
|
||||
|
||||
function getAddButton () {
|
||||
if (state.location.current().url !== 'player') {
|
||||
if (state.location.url() === 'home') {
|
||||
return hx`
|
||||
<i
|
||||
class='icon add'
|
||||
title='Add torrent'
|
||||
onclick=${() => dispatch('showOpenTorrentFile')}>
|
||||
onclick=${dispatcher('openFiles')}>
|
||||
add
|
||||
</i>
|
||||
`
|
||||
|
||||
296
renderer/views/home.js
Normal file
296
renderer/views/home.js
Normal file
@@ -0,0 +1,296 @@
|
||||
module.exports = TorrentList
|
||||
|
||||
var prettyBytes = require('prettier-bytes')
|
||||
|
||||
var hx = require('../lib/hx')
|
||||
var TorrentSummary = require('../lib/torrent-summary')
|
||||
var TorrentPlayer = require('../lib/torrent-player')
|
||||
var {dispatcher} = require('../lib/dispatcher')
|
||||
|
||||
function TorrentList (state) {
|
||||
var torrentRows = state.saved.torrents.map(
|
||||
(torrentSummary) => renderTorrent(torrentSummary)
|
||||
)
|
||||
|
||||
return hx`
|
||||
<div class='torrent-list'>
|
||||
${torrentRows}
|
||||
<div class='torrent-placeholder'>
|
||||
<span class='ellipsis'>Drop a torrent file here or paste a magnet link</span>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
function renderTorrent (torrentSummary) {
|
||||
var infoHash = torrentSummary.infoHash
|
||||
var isSelected = infoHash && state.selectedInfoHash === infoHash
|
||||
|
||||
// Background image: show some nice visuals, like a frame from the movie, if possible
|
||||
var style = {}
|
||||
if (torrentSummary.posterFileName) {
|
||||
var gradient = isSelected
|
||||
? 'linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 100%)'
|
||||
: 'linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%)'
|
||||
var posterPath = TorrentSummary.getPosterPath(torrentSummary)
|
||||
style.backgroundImage = gradient + `, url('${posterPath}')`
|
||||
}
|
||||
|
||||
// Foreground: name of the torrent, basic info like size, play button,
|
||||
// cast buttons if available, and delete
|
||||
var classes = ['torrent']
|
||||
// playStatus turns the play button into a loading spinner or error icon
|
||||
if (torrentSummary.playStatus) classes.push(torrentSummary.playStatus)
|
||||
if (isSelected) classes.push('selected')
|
||||
if (!infoHash) classes.push('disabled')
|
||||
classes = classes.join(' ')
|
||||
return hx`
|
||||
<div style=${style} class=${classes}
|
||||
oncontextmenu=${infoHash && dispatcher('openTorrentContextMenu', infoHash)}
|
||||
onclick=${infoHash && dispatcher('toggleSelectTorrent', infoHash)}>
|
||||
${renderTorrentMetadata(torrentSummary)}
|
||||
${infoHash ? renderTorrentButtons(torrentSummary) : ''}
|
||||
${isSelected ? renderTorrentDetails(torrentSummary) : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Show name, download status, % complete
|
||||
function renderTorrentMetadata (torrentSummary) {
|
||||
var name = torrentSummary.name || 'Loading torrent...'
|
||||
var elements = [hx`
|
||||
<div class='name ellipsis'>${name}</div>
|
||||
`]
|
||||
|
||||
// If it's downloading/seeding then show progress info
|
||||
var prog = torrentSummary.progress
|
||||
if (torrentSummary.status !== 'paused' && prog) {
|
||||
elements.push(hx`
|
||||
<div class='ellipsis'>
|
||||
${renderPercentProgress()}
|
||||
${renderTotalProgress()}
|
||||
${renderPeers()}
|
||||
${renderDownloadSpeed()}
|
||||
${renderUploadSpeed()}
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
return hx`<div class='metadata'>${elements}</div>`
|
||||
|
||||
function renderPercentProgress () {
|
||||
var progress = Math.floor(100 * prog.progress)
|
||||
return hx`<span>${progress}%</span>`
|
||||
}
|
||||
|
||||
function renderTotalProgress () {
|
||||
var downloaded = prettyBytes(prog.downloaded)
|
||||
var total = prettyBytes(prog.length || 0)
|
||||
if (downloaded === total) {
|
||||
return hx`<span>${downloaded}</span>`
|
||||
} else {
|
||||
return hx`<span>${downloaded} / ${total}</span>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderPeers () {
|
||||
if (prog.numPeers === 0) return
|
||||
var count = prog.numPeers === 1 ? 'peer' : 'peers'
|
||||
return hx`<span>${prog.numPeers} ${count}</span>`
|
||||
}
|
||||
|
||||
function renderDownloadSpeed () {
|
||||
if (prog.downloadSpeed === 0) return
|
||||
return hx`<span>↓ ${prettyBytes(prog.downloadSpeed)}/s</span>`
|
||||
}
|
||||
|
||||
function renderUploadSpeed () {
|
||||
if (prog.uploadSpeed === 0) return
|
||||
return hx`<span>↑ ${prettyBytes(prog.uploadSpeed)}/s</span>`
|
||||
}
|
||||
}
|
||||
|
||||
// Download button toggles between torrenting (DL/seed) and paused
|
||||
// Play button starts streaming the torrent immediately, unpausing if needed
|
||||
function renderTorrentButtons (torrentSummary) {
|
||||
var infoHash = torrentSummary.infoHash
|
||||
|
||||
var playIcon, playTooltip, playClass
|
||||
if (torrentSummary.playStatus === 'timeout') {
|
||||
playIcon = 'warning'
|
||||
playTooltip = 'Playback timed out. No seeds? No internet? Click to try again.'
|
||||
} else {
|
||||
playIcon = 'play_arrow'
|
||||
playTooltip = 'Start streaming'
|
||||
}
|
||||
|
||||
var downloadIcon, downloadTooltip
|
||||
if (torrentSummary.status === 'seeding') {
|
||||
downloadIcon = 'file_upload'
|
||||
downloadTooltip = 'Seeding. Click to stop.'
|
||||
} else if (torrentSummary.status === 'downloading') {
|
||||
downloadIcon = 'file_download'
|
||||
downloadTooltip = 'Torrenting. Click to stop.'
|
||||
} else {
|
||||
downloadIcon = 'file_download'
|
||||
downloadTooltip = 'Click to start torrenting.'
|
||||
}
|
||||
|
||||
// Do we have a saved position? Show it using a radial progress bar on top
|
||||
// of the play button, unless already showing a spinner there:
|
||||
var positionElem
|
||||
var willShowSpinner = torrentSummary.playStatus === 'requested'
|
||||
var defaultFile = torrentSummary.files &&
|
||||
torrentSummary.files[torrentSummary.defaultPlayFileIndex]
|
||||
if (defaultFile && defaultFile.currentTime && !willShowSpinner) {
|
||||
var fraction = defaultFile.currentTime / defaultFile.duration
|
||||
positionElem = renderRadialProgressBar(fraction, 'radial-progress-large')
|
||||
playClass = 'resume-position'
|
||||
}
|
||||
|
||||
// Only show the play button for torrents that contain playable media
|
||||
var playButton
|
||||
if (TorrentPlayer.isPlayableTorrent(torrentSummary)) {
|
||||
playButton = hx`
|
||||
<i.button-round.icon.play
|
||||
title=${playTooltip}
|
||||
class=${playClass}
|
||||
onclick=${dispatcher('play', infoHash)}>
|
||||
${playIcon}
|
||||
</i>
|
||||
`
|
||||
}
|
||||
|
||||
return hx`
|
||||
<div class='buttons'>
|
||||
${positionElem}
|
||||
${playButton}
|
||||
<i.button-round.icon.download
|
||||
class=${torrentSummary.status}
|
||||
title=${downloadTooltip}
|
||||
onclick=${dispatcher('toggleTorrent', infoHash)}>
|
||||
${downloadIcon}
|
||||
</i>
|
||||
<i
|
||||
class='icon delete'
|
||||
title='Remove torrent'
|
||||
onclick=${dispatcher('deleteTorrent', infoHash)}>
|
||||
close
|
||||
</i>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Show files, per-file download status and play buttons, and so on
|
||||
function renderTorrentDetails (torrentSummary) {
|
||||
var filesElement
|
||||
if (!torrentSummary.files) {
|
||||
// We don't know what files this torrent contains
|
||||
var message = torrentSummary.status === 'paused'
|
||||
? 'Failed to load torrent info. Click the download button to try again...'
|
||||
: 'Downloading torrent info...'
|
||||
filesElement = hx`<div class='files warning'>${message}</div>`
|
||||
} else {
|
||||
// We do know the files. List them and show download stats for each one
|
||||
var fileRows = torrentSummary.files
|
||||
.map((file, index) => ({ file, index }))
|
||||
.sort(function (a, b) {
|
||||
if (a.file.name < b.file.name) return -1
|
||||
if (b.file.name < a.file.name) return 1
|
||||
return 0
|
||||
})
|
||||
.map((object) => renderFileRow(torrentSummary, object.file, object.index))
|
||||
|
||||
filesElement = hx`
|
||||
<div class='files'>
|
||||
<table>
|
||||
${fileRows}
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
return hx`
|
||||
<div class='torrent-details'>
|
||||
${filesElement}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Show a single torrentSummary file in the details view for a single torrent
|
||||
function renderFileRow (torrentSummary, file, index) {
|
||||
// First, find out how much of the file we've downloaded
|
||||
// Are we even torrenting it?
|
||||
var isSelected = torrentSummary.selections && torrentSummary.selections[index]
|
||||
var isDone = false // Are we finished torrenting it?
|
||||
var progress = ''
|
||||
if (torrentSummary.progress && torrentSummary.progress.files) {
|
||||
var fileProg = torrentSummary.progress.files[index]
|
||||
isDone = fileProg.numPiecesPresent === fileProg.numPieces
|
||||
progress = Math.round(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%'
|
||||
}
|
||||
|
||||
// Second, for media files where we saved our position, show how far we got
|
||||
var positionElem
|
||||
if (file.currentTime) {
|
||||
// Radial progress bar. 0% = start from 0:00, 270% = 3/4 of the way thru
|
||||
positionElem = renderRadialProgressBar(file.currentTime / file.duration)
|
||||
}
|
||||
|
||||
// Finally, render the file as a table row
|
||||
var isPlayable = TorrentPlayer.isPlayable(file)
|
||||
var infoHash = torrentSummary.infoHash
|
||||
var icon
|
||||
var handleClick
|
||||
if (isPlayable) {
|
||||
icon = 'play_arrow' /* playable? add option to play */
|
||||
handleClick = dispatcher('play', infoHash, index)
|
||||
} else {
|
||||
icon = 'description' /* file icon, opens in OS default app */
|
||||
handleClick = dispatcher('openItem', infoHash, index)
|
||||
}
|
||||
var rowClass = ''
|
||||
if (!isSelected) rowClass = 'disabled' // File deselected, not being torrented
|
||||
if (!isDone && !isPlayable) rowClass = 'disabled' // Can't open yet, can't stream
|
||||
return hx`
|
||||
<tr onclick=${handleClick}>
|
||||
<td class='col-icon ${rowClass}'>
|
||||
${positionElem}
|
||||
<i class='icon'>${icon}</i>
|
||||
</td>
|
||||
<td class='col-name ${rowClass}'>
|
||||
${file.name}
|
||||
</td>
|
||||
<td class='col-progress ${rowClass}'>
|
||||
${isSelected ? progress : ''}
|
||||
</td>
|
||||
<td class='col-size ${rowClass}'>
|
||||
${prettyBytes(file.length)}
|
||||
</td>
|
||||
<td class='col-select'
|
||||
onclick=${dispatcher('toggleTorrentFile', infoHash, index)}>
|
||||
<i class='icon'>${isSelected ? 'close' : 'add'}</i>
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
function renderRadialProgressBar (fraction, cssClass) {
|
||||
var rotation = 360 * fraction
|
||||
var transformFill = {transform: 'rotate(' + (rotation / 2) + 'deg)'}
|
||||
var transformFix = {transform: 'rotate(' + rotation + 'deg)'}
|
||||
|
||||
return hx`
|
||||
<div class="radial-progress ${cssClass}">
|
||||
<div class="circle">
|
||||
<div class="mask full" style=${transformFill}>
|
||||
<div class="fill" style=${transformFill}></div>
|
||||
</div>
|
||||
<div class="mask half">
|
||||
<div class="fill" style=${transformFill}></div>
|
||||
<div class="fill fix" style=${transformFix}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inset"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -1,31 +1,29 @@
|
||||
module.exports = OpenTorrentAddressModal
|
||||
|
||||
var h = require('virtual-dom/h')
|
||||
var hyperx = require('hyperx')
|
||||
var hx = hyperx(h)
|
||||
var {dispatch, dispatcher} = require('../lib/dispatcher')
|
||||
var hx = require('../lib/hx')
|
||||
|
||||
function OpenTorrentAddressModal (state, dispatch) {
|
||||
function OpenTorrentAddressModal (state) {
|
||||
return hx`
|
||||
<div class='open-torrent-address-modal'>
|
||||
<p><strong>Enter torrent address or magnet link</strong></p>
|
||||
<p><label>Enter torrent address or magnet link</label></p>
|
||||
<p>
|
||||
<input id='add-torrent-url' type='text' autofocus onkeypress=${handleKeyPress} />
|
||||
<button class='primary' onclick=${handleOK}>OK</button>
|
||||
<button class='cancel' onclick=${handleCancel}>Cancel</button>
|
||||
<input id='add-torrent-url' type='text' onkeypress=${handleKeyPress} />
|
||||
</p>
|
||||
<p class='float-right'>
|
||||
<button class='button button-flat' onclick=${dispatcher('exitModal')}>Cancel</button>
|
||||
<button class='button button-raised' onclick=${handleOK}>OK</button>
|
||||
</p>
|
||||
<script>document.querySelector('#add-torrent-url').focus()</script>
|
||||
</div>
|
||||
`
|
||||
|
||||
function handleKeyPress (e) {
|
||||
if (e.which === 13) handleOK() /* hit Enter to submit */
|
||||
}
|
||||
|
||||
function handleOK () {
|
||||
dispatch('exitModal')
|
||||
dispatch('addTorrent', document.querySelector('#add-torrent-url').value)
|
||||
}
|
||||
|
||||
function handleCancel () {
|
||||
dispatch('exitModal')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyPress (e) {
|
||||
if (e.which === 13) handleOK() /* hit Enter to submit */
|
||||
}
|
||||
|
||||
function handleOK () {
|
||||
dispatch('exitModal')
|
||||
dispatch('addTorrent', document.querySelector('#add-torrent-url').value)
|
||||
}
|
||||
|
||||
@@ -1,61 +1,127 @@
|
||||
module.exports = Player
|
||||
|
||||
var h = require('virtual-dom/h')
|
||||
var hyperx = require('hyperx')
|
||||
var hx = hyperx(h)
|
||||
var Bitfield = require('bitfield')
|
||||
var prettyBytes = require('prettier-bytes')
|
||||
var zeroFill = require('zero-fill')
|
||||
|
||||
var hx = require('../lib/hx')
|
||||
var TorrentSummary = require('../lib/torrent-summary')
|
||||
var {dispatch, dispatcher} = require('../lib/dispatcher')
|
||||
|
||||
// Shows a streaming video player. Standard features + Chromecast + Airplay
|
||||
function Player (state, dispatch) {
|
||||
function Player (state) {
|
||||
// Show the video as large as will fit in the window, play immediately
|
||||
// If the video is on Chromecast or Airplay, show a title screen instead
|
||||
var showVideo = state.playing.location === 'local'
|
||||
return hx`
|
||||
<div
|
||||
class='player'
|
||||
onmousemove=${() => dispatch('videoMouseMoved')}>
|
||||
${showVideo ? renderVideo(state, dispatch) : renderCastScreen(state, dispatch)}
|
||||
${renderPlayerControls(state, dispatch)}
|
||||
</div>
|
||||
onwheel=${handleVolumeWheel}
|
||||
onmousemove=${dispatcher('mediaMouseMoved')}>
|
||||
${showVideo ? renderMedia(state) : renderCastScreen(state)}
|
||||
${renderPlayerControls(state)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderVideo (state, dispatch) {
|
||||
// Handles volume change by wheel
|
||||
function handleVolumeWheel (e) {
|
||||
dispatch('changeVolume', (-e.deltaY | e.deltaX) / 500)
|
||||
}
|
||||
|
||||
function renderMedia (state) {
|
||||
if (!state.server) return
|
||||
|
||||
// Unfortunately, play/pause can't be done just by modifying HTML.
|
||||
// Instead, grab the DOM node and play/pause it if necessary
|
||||
var videoElement = document.querySelector('video')
|
||||
if (videoElement !== null) {
|
||||
if (state.video.isPaused && !videoElement.paused) {
|
||||
videoElement.pause()
|
||||
} else if (!state.video.isPaused && videoElement.paused) {
|
||||
videoElement.play()
|
||||
// Get the <video> or <audio> tag
|
||||
var mediaElement = document.querySelector(state.playing.type)
|
||||
if (mediaElement !== null) {
|
||||
if (state.playing.isPaused && !mediaElement.paused) {
|
||||
mediaElement.pause()
|
||||
} else if (!state.playing.isPaused && mediaElement.paused) {
|
||||
mediaElement.play()
|
||||
}
|
||||
// When the user clicks or drags on the progress bar, jump to that position
|
||||
if (state.video.jumpToTime) {
|
||||
videoElement.currentTime = state.video.jumpToTime
|
||||
state.video.jumpToTime = null
|
||||
if (state.playing.jumpToTime) {
|
||||
mediaElement.currentTime = state.playing.jumpToTime
|
||||
state.playing.jumpToTime = null
|
||||
}
|
||||
state.video.currentTime = videoElement.currentTime
|
||||
state.video.duration = videoElement.duration
|
||||
if (state.playing.playbackRate !== mediaElement.playbackRate) {
|
||||
mediaElement.playbackRate = state.playing.playbackRate
|
||||
}
|
||||
// Recover previous volume
|
||||
if (state.previousVolume !== null && isFinite(state.previousVolume)) {
|
||||
mediaElement.volume = state.previousVolume
|
||||
state.previousVolume = null
|
||||
}
|
||||
|
||||
// Set volume
|
||||
if (state.playing.setVolume !== null && isFinite(state.playing.setVolume)) {
|
||||
mediaElement.volume = state.playing.setVolume
|
||||
state.playing.setVolume = null
|
||||
}
|
||||
|
||||
// Switch to the newly added subtitle track, if available
|
||||
var tracks = mediaElement.textTracks || []
|
||||
for (var j = 0; j < tracks.length; j++) {
|
||||
var isSelectedTrack = j === state.playing.subtitles.selectedIndex
|
||||
tracks[j].mode = isSelectedTrack ? 'showing' : 'hidden'
|
||||
}
|
||||
|
||||
// Save video position
|
||||
var file = state.getPlayingFileSummary()
|
||||
file.currentTime = state.playing.currentTime = mediaElement.currentTime
|
||||
file.duration = state.playing.duration = mediaElement.duration
|
||||
state.playing.volume = mediaElement.volume
|
||||
}
|
||||
|
||||
// Add subtitles to the <video> tag
|
||||
var trackTags = []
|
||||
if (state.playing.subtitles.selectedIndex >= 0) {
|
||||
for (var i = 0; i < state.playing.subtitles.tracks.length; i++) {
|
||||
var track = state.playing.subtitles.tracks[i]
|
||||
var isSelected = state.playing.subtitles.selectedIndex === i
|
||||
trackTags.push(hx`
|
||||
<track
|
||||
${isSelected ? 'default' : ''}
|
||||
label=${track.label}
|
||||
type='subtitles'
|
||||
src=${track.buffer}>
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the <audio> or <video> tag
|
||||
var mediaTag = hx`
|
||||
<div
|
||||
src='${state.server.localURL}'
|
||||
ondblclick=${dispatcher('toggleFullScreen')}
|
||||
onloadedmetadata=${onLoadedMetadata}
|
||||
onended=${onEnded}
|
||||
onstalling=${dispatcher('mediaStalled')}
|
||||
onerror=${dispatcher('mediaError')}
|
||||
ontimeupdate=${dispatcher('mediaTimeUpdate')}
|
||||
onencrypted=${dispatcher('mediaEncrypted')}
|
||||
oncanplay=${onCanPlay}>
|
||||
${trackTags}
|
||||
</div>
|
||||
`
|
||||
mediaTag.tagName = state.playing.type // conditional tag name
|
||||
|
||||
// Show the media.
|
||||
return hx`
|
||||
<div
|
||||
class='letterbox'
|
||||
onmousemove=${() => dispatch('videoMouseMoved')}>
|
||||
<video
|
||||
src='${state.server.localURL}'
|
||||
ondblclick=${() => dispatch('toggleFullScreen')}
|
||||
onloadedmetadata=${onLoadedMetadata}
|
||||
onended=${onEnded}
|
||||
onplay=${() => dispatch('videoPlaying')}
|
||||
onpause=${() => dispatch('videoPaused')}
|
||||
autoplay>
|
||||
</video>
|
||||
onmousemove=${dispatcher('mediaMouseMoved')}>
|
||||
${mediaTag}
|
||||
${renderOverlay(state)}
|
||||
</div>
|
||||
`
|
||||
|
||||
// As soon as the video loads enough to know the video dimensions, resize the window
|
||||
// As soon as we know the video dimensions, resize the window
|
||||
function onLoadedMetadata (e) {
|
||||
if (state.playing.type !== 'video') return
|
||||
var video = e.target
|
||||
var dimensions = {
|
||||
width: video.videoWidth,
|
||||
@@ -66,159 +132,467 @@ function renderVideo (state, dispatch) {
|
||||
|
||||
// When the video completes, pause the video instead of looping
|
||||
function onEnded (e) {
|
||||
state.video.isPaused = true
|
||||
state.playing.isPaused = true
|
||||
}
|
||||
|
||||
function onCanPlay (e) {
|
||||
var elem = e.target
|
||||
if (state.playing.type === 'video' &&
|
||||
elem.webkitVideoDecodedByteCount === 0) {
|
||||
dispatch('mediaError', 'Video codec unsupported')
|
||||
} else if (elem.webkitAudioDecodedByteCount === 0) {
|
||||
dispatch('mediaError', 'Audio codec unsupported')
|
||||
} else {
|
||||
dispatch('mediaSuccess')
|
||||
elem.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderCastScreen (state, dispatch) {
|
||||
var isChromecast = state.playing.location.startsWith('chromecast')
|
||||
var isAirplay = state.playing.location.startsWith('airplay')
|
||||
var isStarting = state.playing.location.endsWith('-pending')
|
||||
if (!isChromecast && !isAirplay) throw new Error('Unimplemented cast type')
|
||||
function renderOverlay (state) {
|
||||
var elems = []
|
||||
var audioMetadataElem = renderAudioMetadata(state)
|
||||
var spinnerElem = renderLoadingSpinner(state)
|
||||
if (audioMetadataElem) elems.push(audioMetadataElem)
|
||||
if (spinnerElem) elems.push(spinnerElem)
|
||||
|
||||
// Show a nice title image, if possible
|
||||
var style = {}
|
||||
var infoHash = state.playing.infoHash
|
||||
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === infoHash)
|
||||
if (torrentSummary && torrentSummary.posterURL) {
|
||||
var cleanURL = torrentSummary.posterURL.replace(/\\/g, '/')
|
||||
style.backgroundImage = `radial-gradient(circle at center, rgba(0,0,0,0.4) 0%,rgba(0,0,0,1) 100%), url(${cleanURL})`
|
||||
// Video fills the window, centered with black bars if necessary
|
||||
// Audio gets a static poster image and a summary of the file metadata.
|
||||
var style
|
||||
if (state.playing.type === 'audio') {
|
||||
style = { backgroundImage: cssBackgroundImagePoster(state) }
|
||||
} else if (elems.length !== 0) {
|
||||
style = { backgroundImage: cssBackgroundImageDarkGradient() }
|
||||
} else {
|
||||
// Video playing, so no spinner. No overlay needed
|
||||
return
|
||||
}
|
||||
|
||||
return hx`
|
||||
<div class='media-overlay-background' style=${style}>
|
||||
<div class='media-overlay'>${elems}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderAudioMetadata (state) {
|
||||
var fileSummary = state.getPlayingFileSummary()
|
||||
if (!fileSummary.audioInfo) return
|
||||
var info = fileSummary.audioInfo
|
||||
|
||||
// Get audio track info
|
||||
var title = info.title
|
||||
if (!title) {
|
||||
title = fileSummary.name
|
||||
}
|
||||
var artist = info.artist && info.artist[0]
|
||||
var album = info.album
|
||||
if (album && info.year && !album.includes(info.year)) {
|
||||
album += ' (' + info.year + ')'
|
||||
}
|
||||
var track
|
||||
if (info.track && info.track.no && info.track.of) {
|
||||
track = info.track.no + ' of ' + info.track.of
|
||||
}
|
||||
|
||||
// Show a small info box in the middle of the screen with title/album/etc
|
||||
var elems = []
|
||||
if (artist) {
|
||||
elems.push(hx`
|
||||
<div class='audio-artist'>
|
||||
<label>Artist</label>${artist}
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
if (album) {
|
||||
elems.push(hx`
|
||||
<div class='audio-album'>
|
||||
<label>Album</label>${album}
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
if (track) {
|
||||
elems.push(hx`
|
||||
<div class='audio-track'>
|
||||
<label>Track</label>${track}
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
// Align the title with the other info, if available. Otherwise, center title
|
||||
var emptyLabel = hx`<label></label>`
|
||||
elems.unshift(hx`
|
||||
<div class='audio-title'>
|
||||
${elems.length ? emptyLabel : undefined}${title}
|
||||
</div>
|
||||
`)
|
||||
|
||||
return hx`<div class='audio-metadata'>${elems}</div>`
|
||||
}
|
||||
|
||||
function renderLoadingSpinner (state) {
|
||||
if (state.playing.isPaused) return
|
||||
var isProbablyStalled = state.playing.isStalled ||
|
||||
(new Date().getTime() - state.playing.lastTimeUpdate > 2000)
|
||||
if (!isProbablyStalled) return
|
||||
|
||||
var prog = state.getPlayingTorrentSummary().progress || {}
|
||||
var fileProgress = 0
|
||||
if (prog.files) {
|
||||
var file = prog.files[state.playing.fileIndex]
|
||||
fileProgress = Math.floor(100 * file.numPiecesPresent / file.numPieces)
|
||||
}
|
||||
|
||||
return hx`
|
||||
<div class='media-stalled'>
|
||||
<div class='loading-spinner'> </div>
|
||||
<div class='loading-status ellipsis'>
|
||||
<span class='progress'>${fileProgress}%</span> downloaded,
|
||||
<span>↓ ${prettyBytes(prog.downloadSpeed || 0)}/s</span>
|
||||
<span>↑ ${prettyBytes(prog.uploadSpeed || 0)}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderCastScreen (state) {
|
||||
var castIcon, castType, isCast
|
||||
if (state.playing.location.startsWith('chromecast')) {
|
||||
castIcon = 'cast_connected'
|
||||
castType = 'Chromecast'
|
||||
isCast = true
|
||||
} else if (state.playing.location.startsWith('airplay')) {
|
||||
castIcon = 'airplay'
|
||||
castType = 'AirPlay'
|
||||
isCast = true
|
||||
} else if (state.playing.location.startsWith('dlna')) {
|
||||
castIcon = 'tv'
|
||||
castType = 'DLNA'
|
||||
isCast = true
|
||||
} else if (state.playing.location === 'vlc') {
|
||||
castIcon = 'tv'
|
||||
castType = 'VLC'
|
||||
isCast = false
|
||||
} else if (state.playing.location === 'error') {
|
||||
castIcon = 'error_outline'
|
||||
castType = 'Error'
|
||||
isCast = false
|
||||
}
|
||||
|
||||
var isStarting = state.playing.location.endsWith('-pending')
|
||||
var castName = state.playing.castName
|
||||
var castStatus
|
||||
if (isCast && isStarting) castStatus = 'Connecting to ' + castName + '...'
|
||||
else if (isCast && !isStarting) castStatus = 'Connected to ' + castName
|
||||
else castStatus = ''
|
||||
|
||||
// Show a nice title image, if possible
|
||||
var style = {
|
||||
backgroundImage: cssBackgroundImagePoster(state)
|
||||
}
|
||||
|
||||
// Show whether we're connected to Chromecast / Airplay
|
||||
var castStatus = isStarting ? 'Connecting...' : 'Connected'
|
||||
return hx`
|
||||
<div class='letterbox' style=${style}>
|
||||
<div class='cast-screen'>
|
||||
<i class='icon'>${isAirplay ? 'airplay' : 'cast'}</i>
|
||||
<div class='cast-type'>${isAirplay ? 'AirPlay' : 'Chromecast'}</div>
|
||||
<i class='icon'>${castIcon}</i>
|
||||
<div class='cast-type'>${castType}</div>
|
||||
<div class='cast-status'>${castStatus}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderPlayerControls (state, dispatch) {
|
||||
var positionPercent = 100 * state.video.currentTime / state.video.duration
|
||||
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' }
|
||||
function renderCastOptions (state) {
|
||||
if (!state.devices.castMenu) return
|
||||
|
||||
var {location, devices} = state.devices.castMenu
|
||||
var player = state.devices[location]
|
||||
|
||||
var items = devices.map(function (device, ix) {
|
||||
var isSelected = player.device === device
|
||||
var name = device.name
|
||||
return hx`
|
||||
<li onclick=${dispatcher('selectCastDevice', ix)}>
|
||||
<i.icon>${isSelected ? 'radio_button_checked' : 'radio_button_unchecked'}</i>
|
||||
${name}
|
||||
</li>
|
||||
`
|
||||
})
|
||||
|
||||
return hx`
|
||||
<ul.options-list>
|
||||
${items}
|
||||
</ul>
|
||||
`
|
||||
}
|
||||
|
||||
function renderSubtitlesOptions (state) {
|
||||
var subtitles = state.playing.subtitles
|
||||
if (!subtitles.tracks.length || !subtitles.showMenu) return
|
||||
|
||||
var items = subtitles.tracks.map(function (track, ix) {
|
||||
var isSelected = state.playing.subtitles.selectedIndex === ix
|
||||
return hx`
|
||||
<li onclick=${dispatcher('selectSubtitle', ix)}>
|
||||
<i.icon>${'radio_button_' + (isSelected ? 'checked' : 'unchecked')}</i>
|
||||
${track.label}
|
||||
</li>
|
||||
`
|
||||
})
|
||||
|
||||
var noneSelected = state.playing.subtitles.selectedIndex === -1
|
||||
var noneClass = 'radio_button_' + (noneSelected ? 'checked' : 'unchecked')
|
||||
return hx`
|
||||
<ul.options-list>
|
||||
${items}
|
||||
<li onclick=${dispatcher('selectSubtitle', -1)}>
|
||||
<i.icon>${noneClass}</i>
|
||||
None
|
||||
</li>
|
||||
</ul>
|
||||
`
|
||||
}
|
||||
|
||||
function renderPlayerControls (state) {
|
||||
var positionPercent = 100 * state.playing.currentTime / state.playing.duration
|
||||
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 3px)' }
|
||||
var captionsClass = state.playing.subtitles.tracks.length === 0
|
||||
? 'disabled'
|
||||
: state.playing.subtitles.selectedIndex >= 0
|
||||
? 'active'
|
||||
: ''
|
||||
|
||||
var elements = [
|
||||
hx`
|
||||
<div class='playback-bar'>
|
||||
${renderLoadingBar(state)}
|
||||
<div class='playback-cursor' style=${playbackCursorStyle}></div>
|
||||
<div class='scrub-bar'
|
||||
<div
|
||||
class='playback-cursor'
|
||||
style=${playbackCursorStyle}>
|
||||
</div>
|
||||
<div
|
||||
class='scrub-bar'
|
||||
draggable='true'
|
||||
ondragstart=${handleDragStart}
|
||||
onclick=${handleScrub},
|
||||
ondrag=${handleScrub}></div>
|
||||
ondrag=${handleScrub}>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
hx`
|
||||
<i class='icon fullscreen'
|
||||
onclick=${() => dispatch('toggleFullScreen')}>
|
||||
<i class='icon play-pause float-left' onclick=${dispatcher('playPause')}>
|
||||
${state.playing.isPaused ? 'play_arrow' : 'pause'}
|
||||
</i>
|
||||
`,
|
||||
hx`
|
||||
<i
|
||||
class='icon fullscreen float-right'
|
||||
onclick=${dispatcher('toggleFullScreen')}>
|
||||
${state.window.isFullScreen ? 'fullscreen_exit' : 'fullscreen'}
|
||||
</i>
|
||||
`
|
||||
]
|
||||
|
||||
if (state.playing.type === 'video') {
|
||||
// show closed captions icon
|
||||
elements.push(hx`
|
||||
<i.icon.closed-caption.float-right
|
||||
class=${captionsClass}
|
||||
onclick=${handleSubtitles}>
|
||||
closed_caption
|
||||
</i>
|
||||
`)
|
||||
}
|
||||
|
||||
// If we've detected a Chromecast or AppleTV, the user can play video there
|
||||
var isOnChromecast = state.playing.location.startsWith('chromecast')
|
||||
var isOnAirplay = state.playing.location.startsWith('airplay')
|
||||
var chromecastClass, chromecastHandler, airplayClass, airplayHandler
|
||||
if (isOnChromecast) {
|
||||
chromecastClass = 'active'
|
||||
airplayClass = 'disabled'
|
||||
chromecastHandler = () => dispatch('stopCasting')
|
||||
airplayHandler = undefined
|
||||
} else if (isOnAirplay) {
|
||||
chromecastClass = 'disabled'
|
||||
airplayClass = 'active'
|
||||
chromecastHandler = undefined
|
||||
airplayHandler = () => dispatch('stopCasting')
|
||||
} else {
|
||||
chromecastClass = ''
|
||||
airplayClass = ''
|
||||
chromecastHandler = () => dispatch('openChromecast')
|
||||
airplayHandler = () => dispatch('openAirplay')
|
||||
var castTypes = ['chromecast', 'airplay', 'dlna']
|
||||
var isCastingAnywhere = castTypes.some(
|
||||
(castType) => state.playing.location.startsWith(castType))
|
||||
|
||||
// Add the cast buttons. Icons for each cast type, connected/disconnected:
|
||||
var buttonIcons = {
|
||||
'chromecast': {true: 'cast_connected', false: 'cast'},
|
||||
'airplay': {true: 'airplay', false: 'airplay'},
|
||||
'dlna': {true: 'tv', false: 'tv'}
|
||||
}
|
||||
if (state.devices.chromecast || isOnChromecast) {
|
||||
castTypes.forEach(function (castType) {
|
||||
// Do we show this button (eg. the Chromecast button) at all?
|
||||
var isCasting = state.playing.location.startsWith(castType)
|
||||
var player = state.devices[castType]
|
||||
if ((!player || player.getDevices().length === 0) && !isCasting) return
|
||||
|
||||
// Show the button. Three options for eg the Chromecast button:
|
||||
var buttonClass, buttonHandler
|
||||
if (isCasting) {
|
||||
// Option 1: we are currently connected to Chromecast. Button stops the cast.
|
||||
buttonClass = 'active'
|
||||
buttonHandler = dispatcher('stopCasting')
|
||||
} else if (isCastingAnywhere) {
|
||||
// Option 2: we are currently connected somewhere else. Button disabled.
|
||||
buttonClass = 'disabled'
|
||||
buttonHandler = undefined
|
||||
} else {
|
||||
// Option 3: we are not connected anywhere. Button opens Chromecast menu.
|
||||
buttonClass = ''
|
||||
buttonHandler = dispatcher('toggleCastMenu', castType)
|
||||
}
|
||||
var buttonIcon = buttonIcons[castType][isCasting]
|
||||
|
||||
elements.push(hx`
|
||||
<i.icon.chromecast
|
||||
class=${chromecastClass}
|
||||
onclick=${chromecastHandler}>
|
||||
cast
|
||||
</i>
|
||||
`)
|
||||
}
|
||||
if (state.devices.airplay || isOnAirplay) {
|
||||
elements.push(hx`
|
||||
<i.icon.airplay
|
||||
class=${airplayClass}
|
||||
onclick=${airplayHandler}>
|
||||
airplay
|
||||
<i.icon.device.float-right
|
||||
class=${buttonClass}
|
||||
onclick=${buttonHandler}>
|
||||
${buttonIcon}
|
||||
</i>
|
||||
`)
|
||||
})
|
||||
|
||||
// Render volume slider
|
||||
var volume = state.playing.volume
|
||||
var volumeIcon = 'volume_' + (
|
||||
volume === 0 ? 'off'
|
||||
: volume < 0.3 ? 'mute'
|
||||
: volume < 0.6 ? 'down'
|
||||
: 'up')
|
||||
var volumeStyle = {
|
||||
background: '-webkit-gradient(linear, left top, right top, ' +
|
||||
'color-stop(' + (volume * 100) + '%, #eee), ' +
|
||||
'color-stop(' + (volume * 100) + '%, #727272))'
|
||||
}
|
||||
|
||||
// On OSX, the back button is in the title bar of the window; see app.js
|
||||
// On other platforms, we render one over the video on mouseover
|
||||
if (process.platform !== 'darwin') {
|
||||
elements.push(hx`
|
||||
<i.icon.back
|
||||
onclick=${() => dispatch('back')}>
|
||||
chevron_left
|
||||
</i>
|
||||
`)
|
||||
}
|
||||
|
||||
// Finally, the big button in the center plays or pauses the video
|
||||
elements.push(hx`
|
||||
<i class='icon play-pause' onclick=${() => dispatch('playPause')}>
|
||||
${state.video.isPaused ? 'play_arrow' : 'pause'}
|
||||
</i>
|
||||
<div class='volume float-left'>
|
||||
<i
|
||||
class='icon volume-icon float-left'
|
||||
onmousedown=${handleVolumeMute}>
|
||||
${volumeIcon}
|
||||
</i>
|
||||
<input
|
||||
class='volume-slider float-right'
|
||||
type='range' min='0' max='1' step='0.05'
|
||||
value=${volumeChanging !== false ? volumeChanging : volume}
|
||||
onmousedown=${handleVolumeScrub}
|
||||
onmouseup=${handleVolumeScrub}
|
||||
onmousemove=${handleVolumeScrub}
|
||||
style=${volumeStyle}
|
||||
/>
|
||||
</div>
|
||||
`)
|
||||
|
||||
return hx`<div class='player-controls'>${elements}</div>`
|
||||
// Show video playback progress
|
||||
var currentTimeStr = formatTime(state.playing.currentTime)
|
||||
var durationStr = formatTime(state.playing.duration)
|
||||
elements.push(hx`
|
||||
<span class='time float-left'>
|
||||
${currentTimeStr} / ${durationStr}
|
||||
</span>
|
||||
`)
|
||||
|
||||
// render playback rate
|
||||
if (state.playing.playbackRate !== 1) {
|
||||
elements.push(hx`
|
||||
<span class='rate float-left'>
|
||||
${state.playing.playbackRate}x
|
||||
</span>
|
||||
`)
|
||||
}
|
||||
|
||||
return hx`
|
||||
<div class='controls'>
|
||||
${elements}
|
||||
${renderCastOptions(state)}
|
||||
${renderSubtitlesOptions(state)}
|
||||
</div>
|
||||
`
|
||||
|
||||
function handleDragStart (e) {
|
||||
// Prevent the cursor from changing, eg to a green + icon on Mac
|
||||
if (e.dataTransfer) {
|
||||
var dt = e.dataTransfer
|
||||
dt.effectAllowed = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// Handles a click or drag to scrub (jump to another position in the video)
|
||||
function handleScrub (e) {
|
||||
dispatch('videoMouseMoved')
|
||||
dispatch('mediaMouseMoved')
|
||||
var windowWidth = document.querySelector('body').clientWidth
|
||||
var fraction = e.clientX / windowWidth
|
||||
var position = fraction * state.video.duration /* seconds */
|
||||
var position = fraction * state.playing.duration /* seconds */
|
||||
dispatch('playbackJump', position)
|
||||
}
|
||||
|
||||
// Handles volume muting and Unmuting
|
||||
function handleVolumeMute (e) {
|
||||
if (state.playing.volume === 0.0) {
|
||||
dispatch('setVolume', 1.0)
|
||||
} else {
|
||||
dispatch('setVolume', 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Handles volume slider scrub
|
||||
function handleVolumeScrub (e) {
|
||||
switch (e.type) {
|
||||
case 'mouseup':
|
||||
volumeChanging = false
|
||||
dispatch('setVolume', e.offsetX / 50)
|
||||
break
|
||||
case 'mousedown':
|
||||
volumeChanging = this.value
|
||||
break
|
||||
case 'mousemove':
|
||||
// only change if move was started by click
|
||||
if (volumeChanging !== false) {
|
||||
volumeChanging = this.value
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubtitles (e) {
|
||||
if (!state.playing.subtitles.tracks.length || e.ctrlKey || e.metaKey) {
|
||||
// if no subtitles available select it
|
||||
dispatch('openSubtitles')
|
||||
} else {
|
||||
dispatch('toggleSubtitlesMenu')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lets scrub without sending to volume backend
|
||||
var volumeChanging = false
|
||||
|
||||
// Renders the loading bar. Shows which parts of the torrent are loaded, which
|
||||
// can be "spongey" / non-contiguous
|
||||
function renderLoadingBar (state) {
|
||||
var torrent = state.client.get(state.playing.infoHash)
|
||||
if (torrent === null) {
|
||||
var torrentSummary = state.getPlayingTorrentSummary()
|
||||
if (!torrentSummary.progress) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Find all contiguous parts of the torrent which are loaded
|
||||
var prog = torrentSummary.progress
|
||||
var fileProg = prog.files[state.playing.fileIndex]
|
||||
var parts = []
|
||||
var lastPartPresent = false
|
||||
var numParts = torrent.pieces.length
|
||||
for (var i = 0; i < numParts; i++) {
|
||||
var partPresent = torrent.bitfield.get(i)
|
||||
if (partPresent && !lastPartPresent) {
|
||||
parts.push({start: i, count: 1})
|
||||
var lastPiecePresent = false
|
||||
for (var i = fileProg.startPiece; i <= fileProg.endPiece; i++) {
|
||||
var partPresent = Bitfield.prototype.get.call(prog.bitfield, i)
|
||||
if (partPresent && !lastPiecePresent) {
|
||||
parts.push({start: i - fileProg.startPiece, count: 1})
|
||||
} else if (partPresent) {
|
||||
parts[parts.length - 1].count++
|
||||
}
|
||||
lastPartPresent = partPresent
|
||||
lastPiecePresent = partPresent
|
||||
}
|
||||
|
||||
// Output an list of rectangles to show loading progress
|
||||
// Output some bars to show which parts of the file are loaded
|
||||
return hx`
|
||||
<div class='loading-bar'>
|
||||
${parts.map(function (part) {
|
||||
var style = {
|
||||
left: (100 * part.start / numParts) + '%',
|
||||
width: (100 * part.count / numParts) + '%'
|
||||
left: (100 * part.start / fileProg.numPieces) + '%',
|
||||
width: (100 * part.count / fileProg.numPieces) + '%'
|
||||
}
|
||||
|
||||
return hx`<div class='loading-bar-part' style=${style}></div>`
|
||||
@@ -226,3 +600,31 @@ function renderLoadingBar (state) {
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Returns the CSS background-image string for a poster image + dark vignette
|
||||
function cssBackgroundImagePoster (state) {
|
||||
var torrentSummary = state.getPlayingTorrentSummary()
|
||||
var posterPath = TorrentSummary.getPosterPath(torrentSummary)
|
||||
if (!posterPath) return ''
|
||||
return cssBackgroundImageDarkGradient() + `, url(${posterPath})`
|
||||
}
|
||||
|
||||
function cssBackgroundImageDarkGradient () {
|
||||
return 'radial-gradient(circle at center, ' +
|
||||
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)'
|
||||
}
|
||||
|
||||
function formatTime (time) {
|
||||
if (typeof time !== 'number' || Number.isNaN(time)) {
|
||||
return '0:00'
|
||||
}
|
||||
|
||||
var hours = Math.floor(time / 3600)
|
||||
var minutes = Math.floor(time % 3600 / 60)
|
||||
if (hours > 0) {
|
||||
minutes = zeroFill(2, minutes)
|
||||
}
|
||||
var seconds = zeroFill(2, Math.floor(time % 60))
|
||||
|
||||
return (hours > 0 ? hours + ':' : '') + minutes + ':' + seconds
|
||||
}
|
||||
|
||||
102
renderer/views/preferences.js
Normal file
102
renderer/views/preferences.js
Normal file
@@ -0,0 +1,102 @@
|
||||
module.exports = Preferences
|
||||
|
||||
var hx = require('../lib/hx')
|
||||
var {dispatch} = require('../lib/dispatcher')
|
||||
|
||||
var remote = require('electron').remote
|
||||
var dialog = remote.dialog
|
||||
|
||||
function Preferences (state) {
|
||||
return hx`
|
||||
<div class='preferences'>
|
||||
${renderGeneralSection(state)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderGeneralSection (state) {
|
||||
return renderSection({
|
||||
title: 'General',
|
||||
description: '',
|
||||
icon: 'settings'
|
||||
}, [
|
||||
renderDownloadDirSelector(state)
|
||||
])
|
||||
}
|
||||
|
||||
function renderDownloadDirSelector (state) {
|
||||
return renderFileSelector({
|
||||
label: 'Download Path',
|
||||
description: 'Data from torrents will be saved here',
|
||||
property: 'downloadPath',
|
||||
options: {
|
||||
title: 'Select download directory',
|
||||
properties: [ 'openDirectory' ]
|
||||
}
|
||||
},
|
||||
state.unsaved.prefs.downloadPath,
|
||||
function (filePath) {
|
||||
setStateValue('downloadPath', filePath)
|
||||
})
|
||||
}
|
||||
|
||||
// Renders a prefs section.
|
||||
// - definition should be {icon, title, description}
|
||||
// - controls should be an array of vdom elements
|
||||
function renderSection (definition, controls) {
|
||||
var helpElem = !definition.description ? null : hx`
|
||||
<div class='help text'>
|
||||
<i.icon>help_outline</i>${definition.description}
|
||||
</div>
|
||||
`
|
||||
return hx`
|
||||
<section class='section preferences-panel'>
|
||||
<div class='section-container'>
|
||||
<div class='section-heading'>
|
||||
<i.icon>${definition.icon}</i>${definition.title}
|
||||
</div>
|
||||
${helpElem}
|
||||
<div class='section-body'>
|
||||
${controls}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
|
||||
// Creates a file chooser
|
||||
// - defition should be {label, description, options}
|
||||
// options are passed to dialog.showOpenDialog
|
||||
// - value should be the current pref, a file or folder path
|
||||
// - callback takes a new file or folder path
|
||||
function renderFileSelector (definition, value, callback) {
|
||||
return hx`
|
||||
<div class='control-group'>
|
||||
<div class='controls'>
|
||||
<label class='control-label'>
|
||||
<div class='preference-title'>${definition.label}</div>
|
||||
<div class='preference-description'>${definition.description}</div>
|
||||
</label>
|
||||
<div class='controls'>
|
||||
<input type='text' class='file-picker-text'
|
||||
id=${definition.property}
|
||||
disabled='disabled'
|
||||
value=${value} />
|
||||
<button class='btn' onclick=${handleClick}>
|
||||
<i.icon>folder_open</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
function handleClick () {
|
||||
dialog.showOpenDialog(remote.getCurrentWindow(), definition.options, function (filenames) {
|
||||
if (!Array.isArray(filenames)) return
|
||||
callback(filenames[0])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setStateValue (property, value) {
|
||||
dispatch('updatePreferences', property, value)
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
module.exports = TorrentList
|
||||
|
||||
var h = require('virtual-dom/h')
|
||||
var hyperx = require('hyperx')
|
||||
var hx = hyperx(h)
|
||||
var prettyBytes = require('prettier-bytes')
|
||||
|
||||
var TorrentPlayer = require('../lib/torrent-player')
|
||||
|
||||
function TorrentList (state, dispatch) {
|
||||
var torrentRows = state.saved.torrents.map(
|
||||
(torrentSummary) => renderTorrent(torrentSummary))
|
||||
return hx`
|
||||
<div class='torrent-list'>
|
||||
${torrentRows}
|
||||
<div class='torrent-placeholder'>
|
||||
<span class='ellipsis'>Drop a torrent file here or paste a magnet link</span>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
// Renders a torrent in the torrent list
|
||||
// Includes name, download status, play button, background image
|
||||
// May be expanded for additional info, including the list of files inside
|
||||
function renderTorrent (torrentSummary) {
|
||||
// Get ephemeral data (like progress %) directly from the WebTorrent handle
|
||||
var infoHash = torrentSummary.infoHash
|
||||
var torrent = state.client.torrents.find((x) => x.infoHash === infoHash)
|
||||
var isSelected = state.selectedInfoHash === infoHash
|
||||
|
||||
// Background image: show some nice visuals, like a frame from the movie, if possible
|
||||
var style = {}
|
||||
if (torrentSummary.posterURL) {
|
||||
var gradient = isSelected
|
||||
? 'linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 100%)'
|
||||
: 'linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%)'
|
||||
// Work around a Chrome bug (reproduced in vanilla Chrome, not just Electron):
|
||||
// Backslashes in URLS in CSS cause bizarre string encoding issues
|
||||
var cleanURL = torrentSummary.posterURL.replace(/\\/g, '/')
|
||||
style.backgroundImage = gradient + `, url('${cleanURL}')`
|
||||
}
|
||||
|
||||
// Foreground: name of the torrent, basic info like size, play button,
|
||||
// cast buttons if available, and delete
|
||||
var classes = ['torrent']
|
||||
// playStatus turns the play button into a loading spinner or error icon
|
||||
if (torrentSummary.playStatus) classes.push(torrentSummary.playStatus)
|
||||
if (isSelected) classes.push('selected')
|
||||
classes = classes.join(' ')
|
||||
return hx`
|
||||
<div style=${style} class=${classes} onclick=${() => dispatch('toggleSelectTorrent', infoHash)}>
|
||||
${renderTorrentMetadata(torrent, torrentSummary)}
|
||||
${renderTorrentButtons(torrentSummary)}
|
||||
${isSelected ? renderTorrentDetails(torrent, torrentSummary) : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Show name, download status, % complete
|
||||
function renderTorrentMetadata (torrent, torrentSummary) {
|
||||
var name = torrentSummary.name || 'Loading torrent...'
|
||||
var elements = [hx`
|
||||
<div class='name ellipsis'>${name}</div>
|
||||
`]
|
||||
|
||||
// If a torrent is paused and we only get the torrentSummary
|
||||
// If it's downloading/seeding then we have more information
|
||||
if (torrent) {
|
||||
var progress = Math.floor(100 * torrent.progress)
|
||||
var downloaded = prettyBytes(torrent.downloaded)
|
||||
var total = prettyBytes(torrent.length || 0)
|
||||
if (downloaded !== total) downloaded += ` / ${total}`
|
||||
|
||||
elements.push(hx`
|
||||
<div class='status ellipsis'>
|
||||
${getFilesLength()}
|
||||
<span>${getPeers()}</span>
|
||||
<span>↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s</span>
|
||||
<span>↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s</span>
|
||||
</div>
|
||||
`)
|
||||
elements.push(hx`
|
||||
<div class='status2 ellipsis'>
|
||||
<span class='progress'>${progress}%</span>
|
||||
<span>${downloaded}</span>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
return hx`<div class='metadata'>${elements}</div>`
|
||||
|
||||
function getPeers () {
|
||||
var count = torrent.numPeers === 1 ? 'peer' : 'peers'
|
||||
return `${torrent.numPeers} ${count}`
|
||||
}
|
||||
|
||||
function getFilesLength () {
|
||||
if (torrent.ready && torrent.files.length > 1) {
|
||||
return hx`<span class='files'>${torrent.files.length} files</span>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download button toggles between torrenting (DL/seed) and paused
|
||||
// Play button starts streaming the torrent immediately, unpausing if needed
|
||||
function renderTorrentButtons (torrentSummary) {
|
||||
var playIcon, playTooltip
|
||||
if (torrentSummary.playStatus === 'unplayable') {
|
||||
playIcon = 'warning'
|
||||
playTooltip = 'Sorry, WebTorrent can\'t play any of the files in this torrent. ' +
|
||||
'View details and click on individual files to open them in another program.'
|
||||
} else if (torrentSummary.playStatus === 'timeout') {
|
||||
playIcon = 'warning'
|
||||
playTooltip = 'Playback timed out. No seeds? No internet? Click to try again.'
|
||||
} else {
|
||||
playIcon = 'play_arrow'
|
||||
playTooltip = 'Start streaming'
|
||||
}
|
||||
|
||||
var downloadIcon, downloadTooltip
|
||||
if (torrentSummary.status === 'seeding') {
|
||||
downloadIcon = 'file_upload'
|
||||
downloadTooltip = 'Seeding. Click to stop.'
|
||||
} else if (torrentSummary.status === 'downloading') {
|
||||
downloadIcon = 'file_download'
|
||||
downloadTooltip = 'Torrenting. Click to stop.'
|
||||
} else {
|
||||
downloadIcon = 'file_download'
|
||||
downloadTooltip = 'Click to start torrenting.'
|
||||
}
|
||||
|
||||
return hx`
|
||||
<div class='buttons'>
|
||||
<i.btn.icon.play
|
||||
title='${playTooltip}'
|
||||
onclick=${(e) => handleButton('play', e)}>
|
||||
${playIcon}
|
||||
</i>
|
||||
<i.btn.icon.download
|
||||
class='${torrentSummary.status}'
|
||||
title='${downloadTooltip}'
|
||||
onclick=${(e) => handleButton('toggleTorrent', e)}>
|
||||
${downloadIcon}
|
||||
</i>
|
||||
<i
|
||||
class='icon delete'
|
||||
title='Remove torrent'
|
||||
onclick=${(e) => handleButton('deleteTorrent', e)}>
|
||||
close
|
||||
</i>
|
||||
</div>
|
||||
`
|
||||
|
||||
function handleButton (action, e) {
|
||||
// Prevent propagation so that we don't select/unselect the torrent
|
||||
e.stopPropagation()
|
||||
dispatch(action, torrentSummary)
|
||||
}
|
||||
}
|
||||
|
||||
// Show files, per-file download status and play buttons, and so on
|
||||
function renderTorrentDetails (torrent, torrentSummary) {
|
||||
var filesElement
|
||||
if (!torrentSummary.files) {
|
||||
// We don't know what files this torrent contains
|
||||
var message = torrent
|
||||
? 'Downloading torrent data using magnet link...'
|
||||
: 'Failed to download torrent data from magnet link. Click the download button to try again...'
|
||||
filesElement = hx`<div class='files warning'>${message}</div>`
|
||||
} else {
|
||||
// We do know the files. List them and show download stats for each one
|
||||
var fileRows = torrentSummary.files.map(
|
||||
(file, index) => renderFileRow(torrent, torrentSummary, file, index))
|
||||
filesElement = hx`
|
||||
<div class='files'>
|
||||
<strong>Files</strong>
|
||||
<span class='open-folder' onclick=${handleOpenFolder}>Open folder</span>
|
||||
<table>
|
||||
${fileRows}
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
return hx`
|
||||
<div class='torrent-details'>
|
||||
${filesElement}
|
||||
</div>
|
||||
`
|
||||
|
||||
function handleOpenFolder (e) {
|
||||
e.stopPropagation()
|
||||
dispatch('openFolder', torrentSummary)
|
||||
}
|
||||
}
|
||||
|
||||
// Show a single torrentSummary file in the details view for a single torrent
|
||||
function renderFileRow (torrent, torrentSummary, file, index) {
|
||||
// First, find out how much of the file we've downloaded
|
||||
var isDone = file.numPiecesPresent === file.numPieces
|
||||
var progress = Math.round(100 * file.numPiecesPresent / (file.numPieces || 0)) + '%'
|
||||
|
||||
// Second, render the file as a table row
|
||||
var icon
|
||||
var rowClass = ''
|
||||
if (state.playing.infoHash === torrentSummary.infoHash && state.playing.fileIndex === index) {
|
||||
icon = 'pause_arrow' /* playing? add option to pause */
|
||||
} else if (TorrentPlayer.isPlayable(file)) {
|
||||
icon = 'play_arrow' /* playable? add option to play */
|
||||
} else {
|
||||
icon = 'description' /* file icon, opens in OS default app */
|
||||
rowClass = isDone ? '' : 'disabled'
|
||||
}
|
||||
return hx`
|
||||
<tr onclick=${handleClick} class='${rowClass}'>
|
||||
<td class='col-icon'>
|
||||
<i class='icon'>${icon}</i>
|
||||
</td>
|
||||
<td class='col-name'>${file.name}</td>
|
||||
<td class='col-progress'>${progress}</td>
|
||||
<td class='col-size'>${prettyBytes(file.length)}</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
// Finally, let the user click on the row to play media or open files
|
||||
function handleClick (e) {
|
||||
e.stopPropagation()
|
||||
if (icon === 'pause_arrow') {
|
||||
throw new Error('Unimplemented') // TODO: pause audio
|
||||
} else if (icon === 'play_arrow') {
|
||||
dispatch('play', torrentSummary, index)
|
||||
} else if (isDone) {
|
||||
dispatch('openFile', torrentSummary, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
renderer/views/unsupported-media-modal.js
Normal file
39
renderer/views/unsupported-media-modal.js
Normal file
@@ -0,0 +1,39 @@
|
||||
module.exports = UnsupportedMediaModal
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var {dispatch, dispatcher} = require('../lib/dispatcher')
|
||||
var hx = require('../lib/hx')
|
||||
|
||||
function UnsupportedMediaModal (state) {
|
||||
var err = state.modal.error
|
||||
var message = (err && err.getMessage)
|
||||
? err.getMessage()
|
||||
: err
|
||||
var actionButton = state.modal.vlcInstalled
|
||||
? hx`<button class="button-raised" onclick=${onPlay}>Play in VLC</button>`
|
||||
: hx`<button class="button-raised" onclick=${onInstall}>Install VLC</button>`
|
||||
var vlcMessage = state.modal.vlcNotFound
|
||||
? 'Couldn\'t run VLC. Please make sure it\'s installed.'
|
||||
: ''
|
||||
return hx`
|
||||
<div>
|
||||
<p><strong>Sorry, we can't play that file.</strong></p>
|
||||
<p>${message}</p>
|
||||
<p class='float-right'>
|
||||
<button class="button-flat" onclick=${dispatcher('backToList')}>Cancel</button>
|
||||
${actionButton}
|
||||
</p>
|
||||
<p class='error-text'>${vlcMessage}</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
function onInstall () {
|
||||
electron.shell.openExternal('http://www.videolan.org/vlc/')
|
||||
state.modal.vlcInstalled = true // Assume they'll install it successfully
|
||||
}
|
||||
|
||||
function onPlay () {
|
||||
dispatch('vlcPlay')
|
||||
}
|
||||
}
|
||||
29
renderer/views/update-available-modal.js
Normal file
29
renderer/views/update-available-modal.js
Normal file
@@ -0,0 +1,29 @@
|
||||
module.exports = UpdateAvailableModal
|
||||
|
||||
var electron = require('electron')
|
||||
|
||||
var {dispatch} = require('../lib/dispatcher')
|
||||
var hx = require('../lib/hx')
|
||||
|
||||
function UpdateAvailableModal (state) {
|
||||
return hx`
|
||||
<div class='update-available-modal'>
|
||||
<p><strong>A new version of WebTorrent is available: v${state.modal.version}</strong></p>
|
||||
<p>We have an auto-updater for Windows and Mac. We don't have one for Linux yet, so you'll have to download the new version manually.</p>
|
||||
<p class='float-right'>
|
||||
<button class='button button-flat' onclick=${handleCancel}>Skip This Release</button>
|
||||
<button class='button button-raised' onclick=${handleOK}>Show Download Page</button>
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
function handleOK () {
|
||||
electron.shell.openExternal('https://github.com/feross/webtorrent-desktop/releases')
|
||||
dispatch('exitModal')
|
||||
}
|
||||
|
||||
function handleCancel () {
|
||||
dispatch('skipVersion', state.modal.version)
|
||||
dispatch('exitModal')
|
||||
}
|
||||
}
|
||||
22
renderer/webtorrent.html
Normal file
22
renderer/webtorrent.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
background-color: #282828;
|
||||
margin: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
img {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script async src="webtorrent.js"></script>
|
||||
<img src="../static/WebTorrent.png">
|
||||
</body>
|
||||
</html>
|
||||
363
renderer/webtorrent.js
Normal file
363
renderer/webtorrent.js
Normal file
@@ -0,0 +1,363 @@
|
||||
// To keep the UI snappy, we run WebTorrent in its own hidden window, a separate
|
||||
// process from the main window.
|
||||
console.time('init')
|
||||
|
||||
var WebTorrent = require('webtorrent')
|
||||
var defaultAnnounceList = require('create-torrent').announceList
|
||||
var deepEqual = require('deep-equal')
|
||||
var electron = require('electron')
|
||||
var fs = require('fs-extra')
|
||||
var musicmetadata = require('musicmetadata')
|
||||
var networkAddress = require('network-address')
|
||||
var path = require('path')
|
||||
|
||||
var crashReporter = require('../crash-reporter')
|
||||
var config = require('../config')
|
||||
var torrentPoster = require('./lib/torrent-poster')
|
||||
|
||||
// Report when the process crashes
|
||||
crashReporter.init()
|
||||
|
||||
// Send & receive messages from the main window
|
||||
var ipc = electron.ipcRenderer
|
||||
|
||||
// Force use of webtorrent trackers on all torrents
|
||||
global.WEBTORRENT_ANNOUNCE = defaultAnnounceList
|
||||
.map((arr) => arr[0])
|
||||
.filter((url) => url.indexOf('wss://') === 0 || url.indexOf('ws://') === 0)
|
||||
|
||||
// Connect to the WebTorrent and BitTorrent networks. WebTorrent Desktop is a hybrid
|
||||
// client, as explained here: https://webtorrent.io/faq
|
||||
var client = new WebTorrent()
|
||||
|
||||
// WebTorrent-to-HTTP streaming sever
|
||||
var server = null
|
||||
|
||||
// Used for diffing, so we only send progress updates when necessary
|
||||
var prevProgress = null
|
||||
|
||||
init()
|
||||
|
||||
function init () {
|
||||
client.on('warning', (err) => ipc.send('wt-warning', null, err.message))
|
||||
client.on('error', (err) => ipc.send('wt-error', null, err.message))
|
||||
|
||||
ipc.on('wt-start-torrenting', (e, torrentKey, torrentID, path, fileModtimes, selections) =>
|
||||
startTorrenting(torrentKey, torrentID, path, fileModtimes, selections))
|
||||
ipc.on('wt-stop-torrenting', (e, infoHash) =>
|
||||
stopTorrenting(infoHash))
|
||||
ipc.on('wt-create-torrent', (e, torrentKey, options) =>
|
||||
createTorrent(torrentKey, options))
|
||||
ipc.on('wt-save-torrent-file', (e, torrentKey) =>
|
||||
saveTorrentFile(torrentKey))
|
||||
ipc.on('wt-generate-torrent-poster', (e, torrentKey) =>
|
||||
generateTorrentPoster(torrentKey))
|
||||
ipc.on('wt-get-audio-metadata', (e, infoHash, index) =>
|
||||
getAudioMetadata(infoHash, index))
|
||||
ipc.on('wt-start-server', (e, infoHash, index) =>
|
||||
startServer(infoHash, index))
|
||||
ipc.on('wt-stop-server', (e) =>
|
||||
stopServer())
|
||||
ipc.on('wt-select-files', (e, infoHash, selections) =>
|
||||
selectFiles(infoHash, selections))
|
||||
|
||||
ipc.send('ipcReadyWebTorrent')
|
||||
|
||||
window.addEventListener('error', (e) =>
|
||||
ipc.send('wt-uncaught-error', {message: e.error.message, stack: e.error.stack}),
|
||||
true)
|
||||
|
||||
setInterval(updateTorrentProgress, 1000)
|
||||
}
|
||||
|
||||
// Starts a given TorrentID, which can be an infohash, magnet URI, etc. Returns WebTorrent object
|
||||
// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-
|
||||
function startTorrenting (torrentKey, torrentID, path, fileModtimes, selections) {
|
||||
console.log('starting torrent %s: %s', torrentKey, torrentID)
|
||||
|
||||
var torrent = client.add(torrentID, {
|
||||
path: path,
|
||||
fileModtimes: fileModtimes
|
||||
})
|
||||
torrent.key = torrentKey
|
||||
|
||||
// Listen for ready event, progress notifications, etc
|
||||
addTorrentEvents(torrent)
|
||||
|
||||
// Only download the files the user wants, not necessarily all files
|
||||
torrent.once('ready', () => selectFiles(torrent, selections))
|
||||
|
||||
return torrent
|
||||
}
|
||||
|
||||
function stopTorrenting (infoHash) {
|
||||
var torrent = client.get(infoHash)
|
||||
if (torrent) torrent.destroy()
|
||||
}
|
||||
|
||||
// Create a new torrent, start seeding
|
||||
function createTorrent (torrentKey, options) {
|
||||
console.log('creating torrent', torrentKey, options)
|
||||
var paths = options.files.map((f) => f.path)
|
||||
var torrent = client.seed(paths, options)
|
||||
torrent.key = torrentKey
|
||||
addTorrentEvents(torrent)
|
||||
ipc.send('wt-new-torrent')
|
||||
}
|
||||
|
||||
function addTorrentEvents (torrent) {
|
||||
torrent.on('warning', (err) =>
|
||||
ipc.send('wt-warning', torrent.key, err.message))
|
||||
torrent.on('error', (err) =>
|
||||
ipc.send('wt-error', torrent.key, err.message))
|
||||
torrent.on('infoHash', () =>
|
||||
ipc.send('wt-infohash', torrent.key, torrent.infoHash))
|
||||
torrent.on('metadata', torrentMetadata)
|
||||
torrent.on('ready', torrentReady)
|
||||
torrent.on('done', torrentDone)
|
||||
|
||||
function torrentMetadata () {
|
||||
var info = getTorrentInfo(torrent)
|
||||
ipc.send('wt-metadata', torrent.key, info)
|
||||
|
||||
updateTorrentProgress()
|
||||
}
|
||||
|
||||
function torrentReady () {
|
||||
var info = getTorrentInfo(torrent)
|
||||
ipc.send('wt-ready', torrent.key, info)
|
||||
ipc.send('wt-ready-' + torrent.infoHash, torrent.key, info) // TODO: hack
|
||||
|
||||
updateTorrentProgress()
|
||||
}
|
||||
|
||||
function torrentDone () {
|
||||
var info = getTorrentInfo(torrent)
|
||||
ipc.send('wt-done', torrent.key, info)
|
||||
|
||||
updateTorrentProgress()
|
||||
|
||||
torrent.getFileModtimes(function (err, fileModtimes) {
|
||||
if (err) return onError(err)
|
||||
ipc.send('wt-file-modtimes', torrent.key, fileModtimes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Produces a JSON saveable summary of a torrent
|
||||
function getTorrentInfo (torrent) {
|
||||
return {
|
||||
infoHash: torrent.infoHash,
|
||||
magnetURI: torrent.magnetURI,
|
||||
name: torrent.name,
|
||||
path: torrent.path,
|
||||
files: torrent.files.map(getTorrentFileInfo),
|
||||
bytesReceived: torrent.received
|
||||
}
|
||||
}
|
||||
|
||||
// Produces a JSON saveable summary of a file in a torrent
|
||||
function getTorrentFileInfo (file) {
|
||||
return {
|
||||
name: file.name,
|
||||
length: file.length,
|
||||
path: file.path
|
||||
}
|
||||
}
|
||||
|
||||
// Every time we resolve a magnet URI, save the torrent file so that we never
|
||||
// have to download it again. Never ask the DHT the same question twice.
|
||||
function saveTorrentFile (torrentKey) {
|
||||
var torrent = getTorrent(torrentKey)
|
||||
checkIfTorrentFileExists(torrent.infoHash, function (torrentPath, exists) {
|
||||
var fileName = torrent.infoHash + '.torrent'
|
||||
if (exists) {
|
||||
// We've already saved the file
|
||||
return ipc.send('wt-file-saved', torrentKey, fileName)
|
||||
}
|
||||
|
||||
// Otherwise, save the .torrent file, under the app config folder
|
||||
fs.mkdir(config.TORRENT_PATH, function (_) {
|
||||
fs.writeFile(torrentPath, torrent.torrentFile, function (err) {
|
||||
if (err) return console.log('error saving torrent file %s: %o', torrentPath, err)
|
||||
console.log('saved torrent file %s', torrentPath)
|
||||
return ipc.send('wt-file-saved', torrentKey, fileName)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Checks whether we've already resolved a given infohash to a torrent file
|
||||
// Calls back with (torrentPath, exists). Logs, does not call back on error
|
||||
function checkIfTorrentFileExists (infoHash, cb) {
|
||||
var torrentPath = path.join(config.TORRENT_PATH, infoHash + '.torrent')
|
||||
fs.exists(torrentPath, function (exists) {
|
||||
cb(torrentPath, exists)
|
||||
})
|
||||
}
|
||||
|
||||
// Save a JPG that represents a torrent.
|
||||
// Auto chooses either a frame from a video file, an image, etc
|
||||
function generateTorrentPoster (torrentKey) {
|
||||
var torrent = getTorrent(torrentKey)
|
||||
torrentPoster(torrent, function (err, buf, extension) {
|
||||
if (err) return console.log('error generating poster: %o', err)
|
||||
// save it for next time
|
||||
fs.mkdirp(config.POSTER_PATH, function (err) {
|
||||
if (err) return console.log('error creating poster dir: %o', err)
|
||||
var posterFileName = torrent.infoHash + extension
|
||||
var posterFilePath = path.join(config.POSTER_PATH, posterFileName)
|
||||
fs.writeFile(posterFilePath, buf, function (err) {
|
||||
if (err) return console.log('error saving poster: %o', err)
|
||||
// show the poster
|
||||
ipc.send('wt-poster', torrentKey, posterFileName)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function updateTorrentProgress () {
|
||||
var progress = getTorrentProgress()
|
||||
// TODO: diff torrent-by-torrent, not once for the whole update
|
||||
if (prevProgress && deepEqual(progress, prevProgress, {strict: true})) {
|
||||
return /* don't send heavy object if it hasn't changed */
|
||||
}
|
||||
ipc.send('wt-progress', progress)
|
||||
prevProgress = progress
|
||||
}
|
||||
|
||||
function getTorrentProgress () {
|
||||
// First, track overall progress
|
||||
var progress = client.progress
|
||||
var hasActiveTorrents = client.torrents.some(function (torrent) {
|
||||
return torrent.progress !== 1
|
||||
})
|
||||
|
||||
// Track progress for every file in each torrent
|
||||
// TODO: ideally this would be tracked by WebTorrent, which could do it
|
||||
// more efficiently than looping over torrent.bitfield
|
||||
var torrentProg = client.torrents.map(function (torrent) {
|
||||
var fileProg = torrent.files && torrent.files.map(function (file, index) {
|
||||
var numPieces = file._endPiece - file._startPiece + 1
|
||||
var numPiecesPresent = 0
|
||||
for (var piece = file._startPiece; piece <= file._endPiece; piece++) {
|
||||
if (torrent.bitfield.get(piece)) numPiecesPresent++
|
||||
}
|
||||
return {
|
||||
startPiece: file._startPiece,
|
||||
endPiece: file._endPiece,
|
||||
numPieces,
|
||||
numPiecesPresent
|
||||
}
|
||||
})
|
||||
return {
|
||||
torrentKey: torrent.key,
|
||||
ready: torrent.ready,
|
||||
progress: torrent.progress,
|
||||
downloaded: torrent.downloaded,
|
||||
downloadSpeed: torrent.downloadSpeed,
|
||||
uploadSpeed: torrent.uploadSpeed,
|
||||
numPeers: torrent.numPeers,
|
||||
length: torrent.length,
|
||||
bitfield: torrent.bitfield,
|
||||
files: fileProg
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
torrents: torrentProg,
|
||||
progress,
|
||||
hasActiveTorrents
|
||||
}
|
||||
}
|
||||
|
||||
function startServer (infoHash, index) {
|
||||
var torrent = client.get(infoHash)
|
||||
if (torrent.ready) startServerFromReadyTorrent(torrent, index)
|
||||
else torrent.once('ready', () => startServerFromReadyTorrent(torrent, index))
|
||||
}
|
||||
|
||||
function startServerFromReadyTorrent (torrent, index, cb) {
|
||||
if (server) return
|
||||
|
||||
// start the streaming torrent-to-http server
|
||||
server = torrent.createServer()
|
||||
server.listen(0, function () {
|
||||
var port = server.address().port
|
||||
var urlSuffix = ':' + port + '/' + index
|
||||
var info = {
|
||||
torrentKey: torrent.key,
|
||||
localURL: 'http://localhost' + urlSuffix,
|
||||
networkURL: 'http://' + networkAddress() + urlSuffix
|
||||
}
|
||||
|
||||
ipc.send('wt-server-running', info)
|
||||
ipc.send('wt-server-' + torrent.infoHash, info) // TODO: hack
|
||||
})
|
||||
}
|
||||
|
||||
function stopServer () {
|
||||
if (!server) return
|
||||
server.destroy()
|
||||
server = null
|
||||
}
|
||||
|
||||
function getAudioMetadata (infoHash, index) {
|
||||
var torrent = client.get(infoHash)
|
||||
var file = torrent.files[index]
|
||||
musicmetadata(file.createReadStream(), function (err, info) {
|
||||
if (err) return
|
||||
console.log('got audio metadata for %s: %o', file.name, info)
|
||||
ipc.send('wt-audio-metadata', infoHash, index, info)
|
||||
})
|
||||
}
|
||||
|
||||
function selectFiles (torrentOrInfoHash, selections) {
|
||||
// Get the torrent object
|
||||
var torrent
|
||||
if (typeof torrentOrInfoHash === 'string') {
|
||||
torrent = client.get(torrentOrInfoHash)
|
||||
} else {
|
||||
torrent = torrentOrInfoHash
|
||||
}
|
||||
|
||||
// Selections not specified?
|
||||
// Load all files. We still need to replace the default whole-torrent
|
||||
// selection with individual selections for each file, so we can
|
||||
// select/deselect files later on
|
||||
if (!selections) {
|
||||
selections = torrent.files.map((x) => true)
|
||||
}
|
||||
|
||||
// Selections specified incorrectly?
|
||||
if (selections.length !== torrent.files.length) {
|
||||
throw new Error('got ' + selections.length + ' file selections, ' +
|
||||
'but the torrent contains ' + torrent.files.length + ' files')
|
||||
}
|
||||
|
||||
// Remove default selection (whole torrent)
|
||||
torrent.deselect(0, torrent.pieces.length - 1, false)
|
||||
|
||||
// Add selections (individual files)
|
||||
for (var i = 0; i < selections.length; i++) {
|
||||
var file = torrent.files[i]
|
||||
if (selections[i]) {
|
||||
file.select()
|
||||
} else {
|
||||
console.log('deselecting file ' + i + ' of torrent ' + torrent.name)
|
||||
file.deselect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gets a WebTorrent handle by torrentKey
|
||||
// Throws an Error if we're not currently torrenting anything w/ that key
|
||||
function getTorrent (torrentKey) {
|
||||
var ret = client.torrents.find((x) => x.key === torrentKey)
|
||||
if (!ret) throw new Error('missing torrent key ' + torrentKey)
|
||||
return ret
|
||||
}
|
||||
|
||||
function onError (err) {
|
||||
console.log(err)
|
||||
}
|
||||
Binary file not shown.
BIN
static/PauseThumbnailBarButton.png
Normal file
BIN
static/PauseThumbnailBarButton.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 B |
BIN
static/PlayThumbnailBarButton.png
Normal file
BIN
static/PlayThumbnailBarButton.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 B |
Binary file not shown.
BIN
static/cosmosLaundromat.jpg
Normal file
BIN
static/cosmosLaundromat.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
static/cosmosLaundromat.torrent
Normal file
BIN
static/cosmosLaundromat.torrent
Normal file
Binary file not shown.
4
static/linux/postinst
Normal file
4
static/linux/postinst
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
chmod +x /opt/webtorrent-desktop/WebTorrent
|
||||
ln -s -f /opt/webtorrent-desktop/WebTorrent /usr/bin/webtorrent-desktop
|
||||
3
static/linux/prerm
Normal file
3
static/linux/prerm
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
rm /usr/bin/webtorrent-desktop
|
||||
33
static/linux/webtorrent-desktop.desktop
Normal file
33
static/linux/webtorrent-desktop.desktop
Normal file
@@ -0,0 +1,33 @@
|
||||
[Desktop Entry]
|
||||
Name=$APP_NAME
|
||||
Version=1.0
|
||||
GenericName=BitTorrent Client
|
||||
X-GNOME-FullName=$APP_NAME
|
||||
Comment=Download and share files over BitTorrent
|
||||
Encoding=UTF-8
|
||||
Type=Application
|
||||
Icon=webtorrent-desktop
|
||||
Terminal=false
|
||||
Path=$APP_PATH
|
||||
Exec=$EXEC_PATH %U
|
||||
TryExec=$TRY_EXEC_PATH
|
||||
StartupNotify=false
|
||||
Categories=Network;FileTransfer;P2P;
|
||||
MimeType=application/x-bittorrent;x-scheme-handler/magnet;x-scheme-handler/stream-magnet;
|
||||
|
||||
Actions=CreateNewTorrent;OpenTorrentFile;OpenTorrentAddress;
|
||||
|
||||
[Desktop Action CreateNewTorrent]
|
||||
Name=Create New Torrent...
|
||||
Exec=$EXEC_PATH -n
|
||||
Path=$APP_PATH
|
||||
|
||||
[Desktop Action OpenTorrentFile]
|
||||
Name=Open Torrent File...
|
||||
Exec=$EXEC_PATH -o
|
||||
Path=$APP_PATH
|
||||
|
||||
[Desktop Action OpenTorrentAddress]
|
||||
Name=Open Torrent Address...
|
||||
Exec=$EXEC_PATH -u
|
||||
Path=$APP_PATH
|
||||
BIN
static/loading.gif
Normal file
BIN
static/loading.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 726 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,16 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=$APP_NAME
|
||||
Version=1.0
|
||||
GenericName=BitTorrent Client
|
||||
X-GNOME-FullName=$APP_NAME
|
||||
Comment=Download and share files over BitTorrent
|
||||
Encoding=UTF-8
|
||||
Type=Application
|
||||
Icon=webtorrent
|
||||
Terminal=false
|
||||
Path=$APP_PATH
|
||||
Exec=$EXEC_PATH %U
|
||||
TryExec=$EXEC_PATH
|
||||
StartupNotify=false
|
||||
Categories=Network;FileTransfer;P2P;
|
||||
MimeType=application/x-bittorrent;x-scheme-handler/magnet;
|
||||
BIN
static/wiredCd.jpg
Normal file
BIN
static/wiredCd.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
BIN
static/wiredCd.torrent
Normal file
BIN
static/wiredCd.torrent
Normal file
Binary file not shown.
Reference in New Issue
Block a user